diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminal-font.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminal-font.test.ts new file mode 100644 index 000000000000..7b3ffeaccb70 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/terminal-font.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + applyTerminalFontFamily, + DEFAULT_TERMINAL_FONT_FAMILY, + prepareTerminalFontFamily, + resolveTerminalFontFamily, + warmTerminalFontFamily +} from './terminal-font' + +describe('terminal font resolution', () => { + it('keeps the bundled stack when no preference is configured', () => { + expect(resolveTerminalFontFamily('')).toBe(DEFAULT_TERMINAL_FONT_FAMILY) + expect(resolveTerminalFontFamily(undefined)).toBe(DEFAULT_TERMINAL_FONT_FAMILY) + }) + + it('quotes a friendly family name and appends the bundled fallback stack', () => { + expect(resolveTerminalFontFamily(' MesloLGS NF ')).toBe(`'MesloLGS NF', ${DEFAULT_TERMINAL_FONT_FAMILY}`) + }) + + it('preserves an authored CSS stack before the bundled fallbacks', () => { + expect(resolveTerminalFontFamily("'Hack Nerd Font', monospace")).toBe( + `'Hack Nerd Font', monospace, ${DEFAULT_TERMINAL_FONT_FAMILY}` + ) + }) +}) + +describe('terminal font lifecycle', () => { + it('warms regular, bold, and italic faces using the effective stack', async () => { + const load = vi.fn().mockResolvedValue([]) + + await warmTerminalFontFamily("'MesloLGS NF', monospace", { load } as Pick) + + expect(load.mock.calls.map(([descriptor]) => descriptor)).toEqual([ + "400 11px 'MesloLGS NF', monospace", + "700 11px 'MesloLGS NF', monospace", + "italic 400 11px 'MesloLGS NF', monospace" + ]) + }) + + it('restarts initial warming when config arrives late', async () => { + let latest = 'fallback' + + const warm = vi.fn(async (fontFamily: string) => { + if (fontFamily === 'fallback') { + latest = 'MesloLGS NF' + } + }) + + await expect( + prepareTerminalFontFamily( + () => latest, + () => true, + warm + ) + ).resolves.toBe('MesloLGS NF') + expect(warm.mock.calls.map(([font]) => font)).toEqual(['fallback', 'MesloLGS NF']) + }) + + it('cancels a stale initial font request before xterm mounts', async () => { + let current = true + + const warm = vi.fn(async () => { + current = false + }) + + await expect( + prepareTerminalFontFamily( + () => 'MesloLGS NF', + () => current, + warm + ) + ).resolves.toBeNull() + }) + + it('updates a mounted terminal without replacing it', async () => { + const term = { + options: { fontFamily: 'fallback' }, + rows: 24, + refresh: vi.fn() + } + + const fit = vi.fn() + const clearTextureAtlas = vi.fn() + + await expect( + applyTerminalFontFamily({ + clearTextureAtlas, + fit, + fontFamily: 'MesloLGS NF', + isCurrent: () => true, + term, + warm: vi.fn().mockResolvedValue(undefined) + }) + ).resolves.toBe(true) + + expect(term.options.fontFamily).toBe('MesloLGS NF') + expect(fit).toHaveBeenCalledOnce() + expect(clearTextureAtlas).toHaveBeenCalledOnce() + expect(term.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('does not paint a stale live font request', async () => { + const term = { + options: { fontFamily: 'newer' }, + rows: 24, + refresh: vi.fn() + } + + await expect( + applyTerminalFontFamily({ + clearTextureAtlas: vi.fn(), + fit: vi.fn(), + fontFamily: 'stale', + isCurrent: () => false, + term, + warm: vi.fn().mockResolvedValue(undefined) + }) + ).resolves.toBe(false) + + expect(term.options.fontFamily).toBe('newer') + expect(term.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminal-font.ts b/apps/desktop/src/app/right-sidebar/terminal/terminal-font.ts new file mode 100644 index 000000000000..d5a7129901ee --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/terminal-font.ts @@ -0,0 +1,138 @@ +import { atom } from 'nanostores' + +export const DEFAULT_TERMINAL_FONT_FAMILY = "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace" + +export const TERMINAL_FONT_SUGGESTIONS = [ + 'MesloLGS NF', + 'JetBrainsMono Nerd Font', + 'CaskaydiaCove Nerd Font', + 'FiraCode Nerd Font', + 'Hack Nerd Font', + 'SauceCodePro Nerd Font', + 'JetBrains Mono', + 'SF Mono', + 'Menlo', + 'Cascadia Code' +] as const + +/** The profile-backed value as written in config.yaml. Empty means bundled default. */ +export const $terminalFontFamily = atom('') + +export function normalizeTerminalFontFamily(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function quoteSingleFamily(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +} + +/** Accept a friendly single family name or an authored CSS font stack. */ +export function resolveTerminalFontFamily(value: unknown): string { + const configured = normalizeTerminalFontFamily(value) + + if (!configured) { + return DEFAULT_TERMINAL_FONT_FAMILY + } + + const preferred = configured.includes(',') || /['"]/.test(configured) ? configured : quoteSingleFamily(configured) + + return `${preferred}, ${DEFAULT_TERMINAL_FONT_FAMILY}` +} + +export function setTerminalFontFamilyFromConfig(value: unknown): void { + $terminalFontFamily.set(normalizeTerminalFontFamily(value)) +} + +type FontFaceLoader = Pick + +function browserFontSet(): FontFaceLoader | undefined { + return typeof document === 'undefined' ? undefined : document.fonts +} + +/** Warm every face xterm uses before WebGL builds its glyph texture atlas. */ +export async function warmTerminalFontFamily( + fontFamily: string, + fontSet: FontFaceLoader | undefined = browserFontSet() +): Promise { + if (!fontSet?.load) { + return + } + + await Promise.allSettled( + ['400', '700', 'italic 400'].map(descriptor => + Promise.resolve().then(() => fontSet.load(`${descriptor} 11px ${fontFamily}`)) + ) + ) +} + +/** + * Wait for the newest requested family before mounting xterm. Config can arrive + * after the terminal component renders; this loop prevents opening WebGL with + * stale fallback metrics and then immediately rebuilding it. + */ +export async function prepareTerminalFontFamily( + getLatest: () => string, + isCurrent: () => boolean, + warm: (fontFamily: string) => Promise = warmTerminalFontFamily +): Promise { + let candidate = getLatest() + + while (isCurrent()) { + await warm(candidate) + + if (!isCurrent()) { + return null + } + + const latest = getLatest() + + if (latest === candidate) { + return candidate + } + + candidate = latest + } + + return null +} + +export interface TerminalFontTarget { + options: { fontFamily?: string } + rows: number + refresh: (start: number, end: number) => void +} + +interface ApplyTerminalFontOptions { + clearTextureAtlas: () => void + fit: () => void + fontFamily: string + isCurrent: () => boolean + term: TerminalFontTarget + warm?: (fontFamily: string) => Promise +} + +/** Apply a live font change without recreating the xterm instance or its PTY. */ +export async function applyTerminalFontFamily({ + clearTextureAtlas, + fit, + fontFamily, + isCurrent, + term, + warm = warmTerminalFontFamily +}: ApplyTerminalFontOptions): Promise { + await warm(fontFamily) + + if (!isCurrent()) { + return false + } + + term.options.fontFamily = fontFamily + fit() + clearTextureAtlas() + + if (term.rows > 0) { + term.refresh(0, term.rows - 1) + } + + return true +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.test.tsx new file mode 100644 index 000000000000..a917f0f7ccc1 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.test.tsx @@ -0,0 +1,156 @@ +import { act, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useAgentTerminal } from './use-agent-terminal' + +const xterm = vi.hoisted(() => ({ + attachCustomKeyEventHandler: vi.fn(), + clearSelection: vi.fn(), + dispose: vi.fn(), + focus: vi.fn(), + getSelection: vi.fn(() => ''), + loadAddon: vi.fn(), + onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })), + open: vi.fn(), + refresh: vi.fn(), + write: vi.fn() +})) + +const terminalRegistrations = vi.hoisted(() => ({ + makeTerminalReader: vi.fn(() => vi.fn()), + registerReader: vi.fn(() => vi.fn()), + registerWriter: vi.fn(() => vi.fn()) +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + readonly buffer = { active: {} } + readonly rows = 24 + readonly unicode = { activeVersion: '6' } + options: Record + + constructor(options: Record) { + this.options = { ...options } + } + + attachCustomKeyEventHandler = xterm.attachCustomKeyEventHandler + clearSelection = xterm.clearSelection + dispose = xterm.dispose + focus = xterm.focus + getSelection = xterm.getSelection + loadAddon = xterm.loadAddon + onSelectionChange = xterm.onSelectionChange + open = xterm.open + refresh = xterm.refresh + write = xterm.write + } +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { + fit = vi.fn() + } +})) + +vi.mock('@xterm/addon-unicode11', () => ({ + Unicode11Addon: class {} +})) + +vi.mock('@xterm/addon-web-links', () => ({ + WebLinksAddon: class {} +})) + +vi.mock('@xterm/addon-webgl', () => ({ + WebglAddon: class { + clearTextureAtlas = vi.fn() + dispose = vi.fn() + onContextLoss = vi.fn() + } +})) + +vi.mock('@/components/ui/copy-button', () => ({ + writeClipboardText: vi.fn() +})) + +vi.mock('@/lib/haptics', () => ({ + triggerHaptic: vi.fn() +})) + +vi.mock('@/themes/context', () => ({ + useTheme: () => ({ + renderedMode: 'dark', + theme: { terminal: {} }, + themeName: 'test' + }) +})) + +vi.mock('./agent-terminal-stream', () => ({ + registerAgentTerminalWriter: terminalRegistrations.registerWriter +})) + +vi.mock('./buffer', () => ({ + makeTerminalReader: terminalRegistrations.makeTerminalReader, + registerTerminalReader: terminalRegistrations.registerReader +})) + +function Harness() { + const { hostRef } = useAgentTerminal({ active: false, id: 'agent-tab', procId: 'proc-1' }) + + return
+} + +describe('useAgentTerminal', () => { + let resolveFontLoad!: (faces: FontFace[]) => void + let resizeObserverConstructor = vi.fn<() => void>() + + beforeEach(() => { + const pendingFontLoad = new Promise(resolve => { + resolveFontLoad = resolve + }) + + Object.defineProperty(globalThis.document, 'fonts', { + configurable: true, + value: { load: vi.fn(() => pendingFontLoad) } + }) + + resizeObserverConstructor = vi.fn<() => void>() + vi.stubGlobal( + 'ResizeObserver', + class { + constructor() { + resizeObserverConstructor() + } + + disconnect = vi.fn() + observe = vi.fn() + unobserve = vi.fn() + } as unknown as typeof ResizeObserver + ) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + Reflect.deleteProperty(globalThis.document, 'fonts') + }) + + it('unmounts safely while initial font preparation is pending', async () => { + const { unmount } = render() + + await waitFor(() => expect(globalThis.document.fonts.load).toHaveBeenCalledTimes(3)) + + expect(() => unmount()).not.toThrow() + expect(xterm.dispose).toHaveBeenCalledOnce() + expect(resizeObserverConstructor).not.toHaveBeenCalled() + + await act(async () => { + resolveFontLoad([]) + await Promise.resolve() + }) + + expect(xterm.open).not.toHaveBeenCalled() + expect(resizeObserverConstructor).not.toHaveBeenCalled() + expect(terminalRegistrations.registerWriter).not.toHaveBeenCalled() + expect(terminalRegistrations.registerReader).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index f87840e95cc7..c0947eb0d9f2 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -13,6 +13,8 @@ import { makeTerminalReader, registerTerminalReader } from './buffer' import { mirrorSelection, terminalClipboardIntent } from './clipboard' import { terminalLinkHandler, terminalWebLinksAddon } from './links' import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection' +import { prepareTerminalFontFamily } from './terminal-font' +import { useTerminalFontController } from './use-terminal-font' // Read-only terminal for an agent background process: a write-only xterm (no PTY, // no input) fed live by the backend output stream, keyed by process id. Shares @@ -23,6 +25,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: const termRef = useRef(null) const webglRef = useRef(null) const fitRef = useRef<(() => void) | null>(null) + const { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef }) const surfaceTheme = () => { const ansi = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal @@ -43,13 +46,20 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: return } + let disposed = false + let observer: ResizeObserver | null = null + + let unregister = () => {} + + let unregisterReader = () => {} + const term = new Terminal({ allowProposedApi: true, allowTransparency: false, convertEol: true, cursorBlink: false, disableStdin: true, - fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace", + fontFamily: latestFontFamilyRef.current, fontSize: 11, fontWeight: 'normal', fontWeightBold: 'bold', @@ -66,8 +76,6 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: term.loadAddon(new Unicode11Addon()) term.loadAddon(terminalWebLinksAddon()) term.unicode.activeVersion = '11' - term.open(host) - termRef.current = term // Read-only mirror, but the output is exactly what people want to copy. // No paste path: this terminal has no PTY to paste into. @@ -103,31 +111,56 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: } } - try { - const webgl = new WebglAddon() - webgl.onContextLoss(() => { - webgl.dispose() - webglRef.current = null - }) - term.loadAddon(webgl) - webglRef.current = webgl - } catch { - // No WebGL — xterm falls back to the DOM renderer. + const mount = () => { + if (disposed || !host.isConnected) { + return + } + + term.open(host) + termRef.current = term + mountedRef.current = true + + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => { + webgl.dispose() + webglRef.current = null + }) + term.loadAddon(webgl) + webglRef.current = webgl + } catch { + // No WebGL — xterm falls back to the DOM renderer. + } + + fitRef.current?.() + observer = new ResizeObserver(() => fitRef.current?.()) + observer.observe(host) + + // Stream live output straight into the terminal (replays backlog on attach). + unregister = registerAgentTerminalWriter(procId, chunk => term.write(chunk)) + unregisterReader = registerTerminalReader(id, makeTerminalReader(term)) } - fitRef.current() - const observer = new ResizeObserver(() => fitRef.current?.()) - observer.observe(host) + void prepareTerminalFontFamily( + () => latestFontFamilyRef.current, + () => !disposed && host.isConnected + ).then(fontFamily => { + if (!fontFamily) { + return + } - // Stream live output straight into the terminal (replays backlog on attach). - const unregister = registerAgentTerminalWriter(procId, chunk => term.write(chunk)) - const unregisterReader = registerTerminalReader(id, makeTerminalReader(term)) + term.options.fontFamily = fontFamily + mount() + }) return () => { + disposed = true + mountedRef.current = false unregister() unregisterReader() selectionDisposable.dispose() - observer.disconnect() + observer?.disconnect() + fitRef.current = null term.dispose() termRef.current = null webglRef.current = null diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.test.tsx new file mode 100644 index 000000000000..9caff182edd4 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $terminalFontFamily, setTerminalFontFamilyFromConfig } from './terminal-font' +import { useTerminalFontController } from './use-terminal-font' + +describe('useTerminalFontController', () => { + afterEach(() => setTerminalFontFamilyFromConfig('')) + + it('repaints an already-mounted xterm when the profile font changes', async () => { + const term = { + options: { fontFamily: 'fallback' }, + refresh: vi.fn(), + rows: 18 + } + + const fit = vi.fn() + + const clearTextureAtlas = vi.fn() + + const refs = { + fitRef: { current: fit }, + termRef: { current: term }, + webglRef: { current: { clearTextureAtlas } } + } + + const { result } = renderHook(() => + useTerminalFontController(refs as unknown as Parameters[0]) + ) + + result.current.mountedRef.current = true + act(() => $terminalFontFamily.set('MesloLGS NF')) + + await waitFor(() => expect(term.options.fontFamily).toContain('MesloLGS NF')) + expect(fit).toHaveBeenCalledOnce() + expect(clearTextureAtlas).toHaveBeenCalledOnce() + expect(term.refresh).toHaveBeenCalledWith(0, 17) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.ts new file mode 100644 index 000000000000..656968071f0a --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-font.ts @@ -0,0 +1,53 @@ +import { useStore } from '@nanostores/react' +import type { WebglAddon } from '@xterm/addon-webgl' +import type { Terminal } from '@xterm/xterm' +import { useEffect, useRef } from 'react' +import type { RefObject } from 'react' + +import { $terminalFontFamily, applyTerminalFontFamily, resolveTerminalFontFamily } from './terminal-font' + +interface TerminalFontControllerOptions { + fitRef: RefObject<(() => void) | null> + termRef: RefObject + webglRef: RefObject +} + +/** + * Share profile-backed font state across user and agent terminals. The owner + * flips mountedRef only after term.open(); before then, its mount path reads + * latestFontFamilyRef and warms the newest value itself. + */ +export function useTerminalFontController({ fitRef, termRef, webglRef }: TerminalFontControllerOptions) { + const configured = useStore($terminalFontFamily) + const fontFamily = resolveTerminalFontFamily(configured) + const latestFontFamilyRef = useRef(fontFamily) + const mountedRef = useRef(false) + const generationRef = useRef(0) + + latestFontFamilyRef.current = fontFamily + + useEffect(() => { + const term = termRef.current + + if (!mountedRef.current || !term || term.options.fontFamily === fontFamily) { + return + } + + const generation = ++generationRef.current + let cancelled = false + + void applyTerminalFontFamily({ + clearTextureAtlas: () => webglRef.current?.clearTextureAtlas(), + fit: () => fitRef.current?.(), + fontFamily, + isCurrent: () => !cancelled && generationRef.current === generation, + term + }) + + return () => { + cancelled = true + } + }, [fitRef, fontFamily, termRef, webglRef]) + + return { latestFontFamilyRef, mountedRef } +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 888d197a55a7..e4e80fd3a421 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -24,7 +24,9 @@ import { terminalSelectionLabel, terminalTheme } from './selection' +import { prepareTerminalFontFamily } from './terminal-font' import { closeTerminal, updateTerminalRestoreCwd, updateTerminalReviveBuffer } from './terminals' +import { useTerminalFontController } from './use-terminal-font' // How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's // terminal.integrated.persistentSessionScrollback default; the store caps the @@ -419,6 +421,7 @@ export function useTerminalSession({ // Re-fit on activation: a tab hidden via display:none has a 0×0 host, so its // last fit is stale by the time it's shown again. const fitRef = useRef<(() => void) | null>(null) + const { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef }) const [status, setStatus] = useState('starting') const [selection, setSelection] = useState('') const [selectionStyle, setSelectionStyle] = useState(null) @@ -511,7 +514,7 @@ export function useTerminalSession({ allowTransparency: false, convertEol: true, cursorBlink: true, - fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace", + fontFamily: latestFontFamilyRef.current, fontSize: 11, // VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium // (500) as the base, which reads a touch heavy at this size. @@ -918,6 +921,7 @@ export function useTerminalSession({ } term.open(host) + mountedRef.current = true term.focus() // WebGL renderer matches the dashboard ChatPage path; xterm's default DOM @@ -938,18 +942,21 @@ export function useTerminalSession({ startSession() } - // fonts.ready settles only already-requested faces; the regular (400), - // bold (700) and italic aren't asked for until styled output paints (past - // atlas init), so warm them up front — otherwise the WebGL atlas bakes a - // fallback face and the terminal renders thin until a repaint. - const warm = document.fonts?.load - ? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`))) - : Promise.resolve() + void prepareTerminalFontFamily( + () => latestFontFamilyRef.current, + () => !disposed && host.isConnected + ).then(fontFamily => { + if (!fontFamily) { + return + } - void warm.then(mount, mount) + term.options.fontFamily = fontFamily + mount() + }) return () => { disposed = true + mountedRef.current = false cleanup.forEach(run => run()) fitRef.current = null @@ -970,7 +977,7 @@ export function useTerminalSession({ // `id` is stable for the instance's life (keyed by tab id), so listing it // doesn't re-create the shell — it just satisfies the deps check for the // closeTerminal(id) call in onExit. - }, [addSelectionToChat, cwd, id]) + }, [addSelectionToChat, cwd, id, latestFontFamilyRef, mountedRef]) useEffect(() => { const term = termRef.current diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index a5ec2060bf52..ca0ca4dc0a45 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { $terminalFontFamily, setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/terminal-font' import { getHermesConfig } from '@/hermes' import { persistString } from '@/lib/storage' import { @@ -47,6 +48,7 @@ describe('useHermesConfig refreshHermesConfig', () => { setCurrentModelSource('') setCurrentReasoningEffort('') setDefaultReasoningEffort('') + setTerminalFontFamilyFromConfig('') persistString(WORKSPACE_CWD_KEY, null) }) @@ -152,4 +154,40 @@ describe('useHermesConfig refreshHermesConfig', () => { expect($currentReasoningEffort.get()).toBe('low') expect($currentFastMode.get()).toBe(false) }) + + it('loads the profile terminal font for already-mounted terminal surfaces', async () => { + mockConfig({ terminal: { font_family: 'MesloLGS NF' } }) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) + + await act(async () => { + await result.current.refreshHermesConfig() + }) + + expect($terminalFontFamily.get()).toBe('MesloLGS NF') + }) + + it('does not let an older profile response restore its terminal font', async () => { + const profileB = deferred>>() + const profileC = deferred>>() + vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) + + let refreshB!: Promise + let refreshC!: Promise + act(() => { + refreshB = result.current.refreshHermesConfig(true) + refreshC = result.current.refreshHermesConfig(true) + }) + + profileC.resolve({ terminal: { font_family: 'Hack Nerd Font' } }) + await act(async () => { + await refreshC + }) + profileB.resolve({ terminal: { font_family: 'MesloLGS NF' } }) + await act(async () => { + await refreshB + }) + + expect($terminalFontFamily.get()).toBe('Hack Nerd Font') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 59e990c64753..1250e4ca0255 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -1,5 +1,6 @@ import { type MutableRefObject, useCallback, useRef, useState } from 'react' +import { setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/terminal-font' import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' @@ -108,6 +109,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) + setTerminalFontFamilyFromConfig(config.terminal?.font_family) applyAutoSpeakFromConfig(config) applyVoiceStopPhraseFromConfig(config) applyThinkingSoundFromConfig(config) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 8149c13c90d0..998f84bb6cfc 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -27,6 +27,7 @@ import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/use import { MODE_OPTIONS } from './constants' import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { TerminalFontSetting } from './terminal-font-setting' function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { // Preview in the *current* mode: the dark palette in Dark, and the light @@ -430,6 +431,8 @@ export function AppearanceSettings() { title={a.uiScaleTitle} /> + + diff --git a/apps/desktop/src/app/settings/terminal-font-setting.test.tsx b/apps/desktop/src/app/settings/terminal-font-setting.test.tsx new file mode 100644 index 000000000000..3bf746526c26 --- /dev/null +++ b/apps/desktop/src/app/settings/terminal-font-setting.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $terminalFontFamily } from '../right-sidebar/terminal/terminal-font' + +import { TerminalFontSetting } from './terminal-font-setting' + +const mocks = vi.hoisted(() => ({ + cache: vi.fn(), + loadedConfig: {} as Record, + notifyError: vi.fn(), + profileSwitch: null as null | (() => void), + save: vi.fn() +})) + +vi.mock('@/hermes', () => ({ + saveHermesConfig: (config: Record) => mocks.save(config) +})) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + settings: { + appearance: { + terminalFontDesc: 'Choose an installed font.', + terminalFontPlaceholder: 'MesloLGS NF or a CSS font stack', + terminalFontPreview: 'Glyph preview', + terminalFontReset: 'Use default', + terminalFontTitle: 'Terminal Font' + }, + config: { autosaveFailed: 'Autosave failed' } + } + } + }) +})) + +vi.mock('@/store/notifications', () => ({ + notifyError: (...args: unknown[]) => mocks.notifyError(...args) +})) + +vi.mock('../hooks/use-config-record', () => ({ + setHermesConfigCache: (config: Record) => mocks.cache(config), + useHermesConfigRecord: () => ({ data: mocks.loadedConfig }) +})) + +vi.mock('../hooks/use-on-profile-switch', () => ({ + useOnProfileSwitch: (callback: () => void) => { + mocks.profileSwitch = callback + } +})) + +async function flushAutosave() { + await act(async () => { + vi.advanceTimersByTime(550) + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('TerminalFontSetting', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.loadedConfig = { + display: { skin: 'hermes' }, + terminal: { backend: 'local', cwd: '/workspace', font_family: '' } + } + mocks.save.mockResolvedValue({ ok: true }) + mocks.profileSwitch = null + $terminalFontFamily.set('') + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('selects MesloLGS NF and persists only the terminal font field', async () => { + render() + const input = screen.getByRole('combobox', { name: 'Terminal Font' }) + + fireEvent.change(input, { target: { value: 'MesloLGS NF' } }) + + expect($terminalFontFamily.get()).toBe('MesloLGS NF') + expect((screen.getByLabelText('Glyph preview') as HTMLElement).style.fontFamily).toContain('MesloLGS NF') + + await flushAutosave() + + expect(mocks.save).toHaveBeenCalledWith({ + display: { skin: 'hermes' }, + terminal: { backend: 'local', cwd: '/workspace', font_family: 'MesloLGS NF' } + }) + expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0]) + }) + + it('accepts an arbitrary CSS stack and resets to the bundled default', async () => { + mocks.loadedConfig = { + terminal: { backend: 'local', font_family: "'Hack Nerd Font', monospace" } + } + render() + const input = screen.getByRole('combobox', { name: 'Terminal Font' }) + + expect((input as HTMLInputElement).value).toBe("'Hack Nerd Font', monospace") + fireEvent.change(input, { target: { value: "'Custom Powerline', monospace" } }) + await flushAutosave() + + expect(mocks.save.mock.calls[0][0]).toMatchObject({ + terminal: { backend: 'local', font_family: "'Custom Powerline', monospace" } + }) + + fireEvent.click(screen.getByRole('button', { name: 'Use default' })) + expect($terminalFontFamily.get()).toBe('') + expect((screen.getByLabelText('Glyph preview') as HTMLElement).style.fontFamily).toContain('JetBrains Mono') + await flushAutosave() + + expect(mocks.save.mock.calls[1][0]).toMatchObject({ + terminal: { backend: 'local', font_family: '' } + }) + }) + + it('rolls back the optimistic font when autosave fails', async () => { + mocks.loadedConfig = { terminal: { font_family: 'MesloLGS NF' } } + mocks.save.mockRejectedValue(new Error('disk full')) + render() + const input = screen.getByRole('combobox', { name: 'Terminal Font' }) + + fireEvent.change(input, { target: { value: 'Hack Nerd Font' } }) + expect($terminalFontFamily.get()).toBe('Hack Nerd Font') + await flushAutosave() + + expect((input as HTMLInputElement).value).toBe('MesloLGS NF') + expect($terminalFontFamily.get()).toBe('MesloLGS NF') + expect(mocks.notifyError).toHaveBeenCalledWith(expect.any(Error), 'Autosave failed') + }) + + it('drops the prior profile font and reseeds from the next profile', () => { + mocks.loadedConfig = { terminal: { font_family: 'MesloLGS NF' } } + const view = render() + + expect($terminalFontFamily.get()).toBe('MesloLGS NF') + act(() => mocks.profileSwitch?.()) + expect($terminalFontFamily.get()).toBe('') + expect((screen.getByRole('combobox', { name: 'Terminal Font' }) as HTMLInputElement).disabled).toBe(true) + + mocks.loadedConfig = { terminal: { font_family: 'Hack Nerd Font' } } + view.rerender() + + expect((screen.getByRole('combobox', { name: 'Terminal Font' }) as HTMLInputElement).value).toBe('Hack Nerd Font') + expect($terminalFontFamily.get()).toBe('Hack Nerd Font') + }) +}) diff --git a/apps/desktop/src/app/settings/terminal-font-setting.tsx b/apps/desktop/src/app/settings/terminal-font-setting.tsx new file mode 100644 index 000000000000..f19a554865b4 --- /dev/null +++ b/apps/desktop/src/app/settings/terminal-font-setting.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef, useState } from 'react' + +import { + normalizeTerminalFontFamily, + resolveTerminalFontFamily, + setTerminalFontFamilyFromConfig, + TERMINAL_FONT_SUGGESTIONS +} from '@/app/right-sidebar/terminal/terminal-font' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { saveHermesConfig } from '@/hermes' +import { useI18n } from '@/i18n' +import { notifyError } from '@/store/notifications' +import type { HermesConfigRecord } from '@/types/hermes' + +import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' +import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' + +import { getNested, setNested } from './helpers' +import { ListRow } from './primitives' + +const AUTOSAVE_DELAY_MS = 550 + +function fontFamilyFromConfig(config: HermesConfigRecord): string { + return normalizeTerminalFontFamily(getNested(config, 'terminal.font_family')) +} + +export function TerminalFontSetting() { + const { t } = useI18n() + const copy = t.settings.appearance + const { data: loadedConfig } = useHermesConfigRecord() + // draft === null ⇔ unseeded: nothing painted yet for this profile. The + // profile-switch handler resets it to null and records the config object + // it was looking at (`staleConfig`) — the seed effect refuses to re-seed + // from that same object, so the previous profile's cached record can't + // repopulate the field; the next profile's fetch (a new object) seeds it. + // `draft` itself is the seed marker (no ref mirroring, per the lint rule). + const [draft, setDraft] = useState(null) + const [staleConfig, setStaleConfig] = useState(null) + const [saveVersion, setSaveVersion] = useState(0) + const saveVersionRef = useRef(0) + + // Lexically outside every useEffect so async save callbacks can cancel the + // in-flight version without assigning to a ref inside an effect body. + const cancelPendingSave = () => { + saveVersionRef.current = 0 + } + + useEffect(() => { + if (!loadedConfig || draft !== null || loadedConfig === staleConfig) { + return + } + + const value = fontFamilyFromConfig(loadedConfig) + setDraft(value) + setTerminalFontFamilyFromConfig(value) + }, [draft, loadedConfig, staleConfig]) + + useOnProfileSwitch(() => { + saveVersionRef.current += 1 + setDraft(null) + setStaleConfig(loadedConfig ?? null) + setSaveVersion(0) + // Do not show the previous profile's font while the new profile loads. + setTerminalFontFamilyFromConfig('') + }) + + useEffect(() => { + if (draft === null || saveVersion === 0 || !loadedConfig) { + return + } + + const version = saveVersion + const value = normalizeTerminalFontFamily(draft) + + // Already persisted (or a cache refresh confirmed it) — nothing to save. + // This also terminates the effect re-run after a successful save updates + // the shared config cache. + if (value === fontFamilyFromConfig(loadedConfig)) { + return + } + + // The last successfully saved value IS what the shared config cache + // holds — successful saves write it back via setHermesConfigCache, so + // rollback re-derives from there instead of mirroring into a ref. + const rollback = fontFamilyFromConfig(loadedConfig) + + const timeout = window.setTimeout(() => { + const next = setNested(loadedConfig, 'terminal.font_family', value) + + void saveHermesConfig(next) + .then(result => { + if (!result.ok) { + throw new Error(t.settings.config.autosaveFailed) + } + + if (saveVersionRef.current !== version) { + return + } + + setHermesConfigCache(next) + }) + .catch(error => { + if (saveVersionRef.current !== version) { + return + } + + cancelPendingSave() + setSaveVersion(0) + setDraft(rollback) + setTerminalFontFamilyFromConfig(rollback) + notifyError(error, t.settings.config.autosaveFailed) + }) + }, AUTOSAVE_DELAY_MS) + + return () => window.clearTimeout(timeout) + }, [draft, loadedConfig, saveVersion, t.settings.config.autosaveFailed]) + + const update = (value: string) => { + saveVersionRef.current += 1 + setDraft(value) + setSaveVersion(saveVersionRef.current) + setTerminalFontFamilyFromConfig(value) + } + + const value = draft ?? '' + const previewFontFamily = resolveTerminalFontFamily(value) + + return ( + +
+ update(event.target.value)} + placeholder={copy.terminalFontPlaceholder} + value={value} + /> + +
+ + {TERMINAL_FONT_SUGGESTIONS.map(font => ( + +
+ + {copy.terminalFontPreview} + + ~/project git:main ❯ +
+
+ } + description={copy.terminalFontDesc} + title={copy.terminalFontTitle} + wide + /> + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fe1da62d0fe3..c08e289c0374 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -439,6 +439,12 @@ export const en: Translations = { uiScaleTitle: 'UI Scale', uiScaleDesc: (percent: number) => `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, + terminalFontTitle: 'Terminal Font', + terminalFontDesc: + 'Choose an installed font for Desktop terminals. Nerd Fonts render Powerlevel10k and shell icons; leave blank to use bundled JetBrains Mono.', + terminalFontPlaceholder: 'MesloLGS NF or a CSS font stack', + terminalFontPreview: 'Glyph preview', + terminalFontReset: 'Use default', translucencyTitle: 'Window Translucency', translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', backdropTitle: 'Chat Backdrop', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4a31419cce32..a3f949dc9ec8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -315,6 +315,12 @@ export const ja = defineLocale({ uiScaleTitle: 'UI スケール', uiScaleDesc: (percent: number) => `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, + terminalFontTitle: 'ターミナルフォント', + terminalFontDesc: + 'Desktop のターミナルで使用するインストール済みフォントを選びます。Nerd Font は Powerlevel10k とシェルアイコンを表示できます。空欄では内蔵の JetBrains Mono を使用します。', + terminalFontPlaceholder: 'MesloLGS NF または CSS フォントスタック', + terminalFontPreview: 'グリフのプレビュー', + terminalFontReset: '既定値を使用', translucencyTitle: 'ウィンドウの透過', translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 303bd421c313..3511e866a17e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -348,6 +348,11 @@ export interface Translations { toolViewDesc: string uiScaleTitle: string uiScaleDesc: (percent: number) => string + terminalFontTitle: string + terminalFontDesc: string + terminalFontPlaceholder: string + terminalFontPreview: string + terminalFontReset: string translucencyTitle: string translucencyDesc: string backdropTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 2b93bd09bea7..0c382484a045 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -307,6 +307,12 @@ export const zhHant = defineLocale({ uiScaleTitle: '介面縮放', uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + terminalFontTitle: '終端機字型', + terminalFontDesc: + '選擇已安裝的字型用於桌面端終端機。Nerd Font 可正確顯示 Powerlevel10k 與 Shell 圖示;留空則使用內建的 JetBrains Mono。', + terminalFontPlaceholder: 'MesloLGS NF 或 CSS 字型堆疊', + terminalFontPreview: '字形預覽', + terminalFontReset: '使用預設字型', translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4f206a2b2319..0295d4817863 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -431,6 +431,12 @@ export const zh: Translations = { uiScaleTitle: '界面缩放', uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + terminalFontTitle: '终端字体', + terminalFontDesc: + '选择已安装的字体用于桌面端终端。Nerd Font 可正确显示 Powerlevel10k 和 Shell 图标;留空则使用内置的 JetBrains Mono。', + terminalFontPlaceholder: 'MesloLGS NF 或 CSS 字体栈', + terminalFontPreview: '字形预览', + terminalFontReset: '使用默认字体', translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 0b23acbe46a8..796784ff61c2 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -339,6 +339,7 @@ export interface HermesConfig { } terminal?: { cwd?: string + font_family?: string } stt?: { enabled?: boolean diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 95915405bea9..73c24c9e2311 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -230,6 +230,8 @@ model: terminal: backend: "local" cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. + # Desktop xterm font. Install the font locally; Nerd Fonts render Powerlevel10k glyphs. + # font_family: "MesloLGS NF" # Also accepts a CSS stack; blank uses bundled JetBrains Mono. timeout: 180 # HOME policy for tool subprocesses: # auto - default: host uses your real HOME; containers use HERMES_HOME/home diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index d57abef08f1c..74055e47be97 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -239,6 +239,14 @@ "backend": "local", "modal_mode": "auto", "cwd": ".", # Use current directory + # Terminal font family for the desktop app's embedded xterm.js terminal. + # When set (e.g. "'CaskaydiaCoveNerdFont', 'JetBrains Mono', monospace"), + # the desktop terminal uses this as the CSS font-family value, with the + # built-in default ("'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, + # Consolas, monospace") as fallback when the field is empty or unset. + # This lets users install a Nerd Font (or any custom font) and configure + # it here without patching the built desktop app. + "font_family": "", "timeout": 180, # Bounded grace period (seconds) between SIGTERM and an escalated # SIGKILL when terminating a host process tree (browser daemons, etc.). diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 7b7c2160cc3b..1233fa8c20cc 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1556,6 +1556,26 @@ def get_nested(obj, path): mismatches.append(f"{key}: expected list, got {type(val).__name__}") assert not mismatches, "Type mismatches:\n" + "\n".join(mismatches) + def test_desktop_terminal_font_round_trip_preserves_terminal_config(self): + """The Appearance picker persists a font without replacing sibling settings.""" + from hermes_cli.config import load_config + + web_config = self.client.get("/api/config").json() + terminal_before = dict(web_config.get("terminal", {})) + web_config.setdefault("terminal", {})["font_family"] = "MesloLGS NF" + + response = self.client.put("/api/config", json={"config": web_config}) + + assert response.status_code == 200 + persisted = load_config()["terminal"] + assert persisted["font_family"] == "MesloLGS NF" + for key, value in terminal_before.items(): + if key != "font_family": + assert persisted[key] == value + + reloaded = self.client.get("/api/config").json() + assert reloaded["terminal"]["font_family"] == "MesloLGS NF" + # --------------------------------------------------------------------------- # New feature endpoint tests diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 47d63fbe4c8d..f27335dbd41e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -122,6 +122,7 @@ Hermes supports seven terminal backends. Each determines where the agent's shell terminal: backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity cwd: "." # Gateway/cron working directory (CLI always uses launch dir) + font_family: "" # Desktop terminal font; e.g. "MesloLGS NF" timeout: 180 # Per-command timeout in seconds home_mode: auto # auto | real | profile — subprocess HOME policy env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code) @@ -130,6 +131,8 @@ terminal: daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend ``` +`terminal.font_family` controls the embedded terminal in Hermes Desktop. It accepts either one locally installed family name (for example, `MesloLGS NF`) or a CSS font stack. Hermes appends its bundled JetBrains Mono stack as a fallback, and an empty value keeps the default. You can edit the same profile-scoped setting in **Settings → Appearance → Terminal Font**; no Google Fonts download or system-font permission is required. + For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. ### Backend Overview diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 065e41d4bff9..d7c803ed77ba 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -135,6 +135,7 @@ Manage providers, models, tools, and credentials from a real UI instead of editi - **Every provider and model in the menus** — the GUI surfaces the full provider list and every model that `hermes model` knows about, so you pick from the same catalog the CLI sees rather than a curated subset. - **xAI Grok OAuth** — Grok is a first-class OAuth provider in the launcher; sign in through the browser flow like the other OAuth providers. - **Tool-backend installs from the GUI** — run a tool backend's post-setup install steps directly from the app instead of dropping to a terminal. +- **Terminal font picker** — choose an installed font in **Settings → Appearance**. Nerd Fonts such as `MesloLGS NF` render Powerlevel10k separators and icons in both interactive and agent terminals; the setting is saved per profile. - **Auxiliary-model warning** — if you switch the main model to a new provider while auxiliary tasks (titling, summarization, and similar helpers) are still pinned to another provider, the app warns you so you don't unknowingly split work across two providers. - **VS Code Marketplace themes** — beyond the built-in theme presets, the appearance settings include a live VS Code Marketplace search: pick any color theme and the app downloads, converts, and installs it as a desktop theme. The same importer is available from the command palette (*Install theme*), and imported themes can be removed again from the appearance settings. - **Keep computer awake** — **Settings → Advanced → Keep computer awake** stops the machine from sleeping so long or overnight agent runs keep going (the display can still dim). This is a per-computer setting.