diff --git a/web/src/components/HermesConsoleModal.tsx b/web/src/components/HermesConsoleModal.tsx index 069dd8a8e0..322e8fd19a 100644 --- a/web/src/components/HermesConsoleModal.tsx +++ b/web/src/components/HermesConsoleModal.tsx @@ -12,6 +12,10 @@ import { useModalBehavior } from "@/hooks/useModalBehavior"; import { useProfileScope } from "@/contexts/useProfileScope"; import { api } from "@/lib/api"; import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "@/lib/terminal-font-refit"; import { cn, themedBody } from "@/lib/utils"; import { useTheme } from "@/themes"; import { errorMessage } from "@/lib/api-error"; @@ -351,8 +355,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { const term = new XtermTerminal({ allowProposedApi: true, cursorBlink: true, - fontFamily: - "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontFamily: TERMINAL_FONT_FAMILY, fontSize: 13, lineHeight: 1.25, letterSpacing: 0, @@ -394,6 +397,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { const ro = new ResizeObserver(scheduleFit); ro.observe(host); scheduleFit(); + const stopFontRefit = refitWhenTerminalFontLoads(term, fitTerminal); const dataDisposable = term.onData(handleInputData); setConnectionState("connecting"); @@ -462,6 +466,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { dataDisposable.dispose(); ro.disconnect(); if (resizeFrame) cancelAnimationFrame(resizeFrame); + stopFontRefit(); wsRef.current?.close(); wsRef.current = null; term.dispose(); diff --git a/web/src/lib/terminal-font-refit.test.ts b/web/src/lib/terminal-font-refit.test.ts new file mode 100644 index 0000000000..ece0b5d2c3 --- /dev/null +++ b/web/src/lib/terminal-font-refit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "./terminal-font-refit"; + +/** Mirrors xterm: the cell is re-measured only when fontFamily changes. */ +function fakeTerminal() { + let family = TERMINAL_FONT_FAMILY; + const term = { + measuredWith: [] as string[], + options: { + get fontFamily() { + return family; + }, + set fontFamily(next: string) { + if (next === family) return; + family = next; + term.measuredWith.push(next); + }, + }, + rows: 24, + clearTextureAtlas: vi.fn(), + refresh: vi.fn(), + }; + return term; +} + +function deferredFontSet(opts: { loaded?: boolean; faces?: unknown[] } = {}) { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + return { + release, + fontSet: { + check: vi.fn(() => opts.loaded ?? false), + load: vi.fn(async () => { + await gate; + return (opts.faces ?? [{}]) as FontFace[]; + }), + }, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("refitWhenTerminalFontLoads", () => { + it("re-measures with the loaded face, then refits and redraws", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet(); + + refitWhenTerminalFontLoads(term, fit, fontSet); + await settle(); + expect(fit).not.toHaveBeenCalled(); + + release(); + await settle(); + + expect(term.measuredWith.at(-1)).toBe(TERMINAL_FONT_FAMILY); + expect(term.options.fontFamily).toBe(TERMINAL_FONT_FAMILY); + expect(fit).toHaveBeenCalledTimes(1); + expect(term.clearTextureAtlas).toHaveBeenCalledTimes(1); + expect(term.refresh).toHaveBeenCalledWith(0, 23); + }); + + it("does nothing after cleanup runs before the font arrives", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet(); + + const cleanup = refitWhenTerminalFontLoads(term, fit, fontSet); + cleanup(); + release(); + await settle(); + + expect(term.measuredWith).toEqual([]); + expect(fit).not.toHaveBeenCalled(); + }); + + it("skips the refit when the faces were already loaded at open()", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet } = deferredFontSet({ loaded: true }); + + refitWhenTerminalFontLoads(term, fit, fontSet); + await settle(); + + expect(fontSet.load).not.toHaveBeenCalled(); + expect(fit).not.toHaveBeenCalled(); + }); + + it("leaves fallback metrics alone when no bundled face could load", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet({ faces: [] }); + + refitWhenTerminalFontLoads(term, fit, fontSet); + release(); + await settle(); + + expect(term.measuredWith).toEqual([]); + expect(fit).not.toHaveBeenCalled(); + }); + + it("is a no-op without a FontFaceSet", () => { + const fit = vi.fn(); + expect(() => refitWhenTerminalFontLoads(fakeTerminal(), fit, undefined)()).not.toThrow(); + expect(fit).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/terminal-font-refit.ts b/web/src/lib/terminal-font-refit.ts new file mode 100644 index 0000000000..2e63c13352 --- /dev/null +++ b/web/src/lib/terminal-font-refit.ts @@ -0,0 +1,71 @@ +/** + * Dashboard xterm font stack. 'JetBrains Mono' is bundled via @font-face in + * index.css with `font-display: swap`, so it is usually still downloading + * when a terminal first opens. + */ +export const TERMINAL_FONT_FAMILY = + "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace"; + +const BUNDLED_FACES = ["400", "700", "italic 400"].map( + (descriptor) => `${descriptor} 1em 'JetBrains Mono'`, +); + +type TerminalFontSet = Pick; + +export interface RemeasurableTerminal { + options: { fontFamily?: string }; + rows: number; + clearTextureAtlas(): void; + refresh(start: number, end: number): void; +} + +function browserFontSet(): TerminalFontSet | undefined { + return typeof document === "undefined" ? undefined : document.fonts; +} + +/** + * xterm measures its cell size once at open() and afterwards only when + * fontFamily/fontSize *change* or the grid resizes. When the bundled font + * swaps in later, the grid keeps fallback-font metrics (and the WebGL atlas + * keeps fallback glyphs) until something resizes the host, e.g. toggling the + * sidebar (#92899). Once the bundled faces load, force a re-measure, refit, + * and redraw. Returns a cleanup that drops a still-pending load. + */ +export function refitWhenTerminalFontLoads( + term: RemeasurableTerminal, + fit: () => void, + fontSet: TerminalFontSet | undefined = browserFontSet(), +): () => void { + if (!fontSet?.load) return () => undefined; + try { + if (BUNDLED_FACES.every((face) => fontSet.check(face))) { + return () => undefined; + } + } catch { + /* check() throws on unparsable descriptors in some engines; just load */ + } + + let cancelled = false; + void Promise.allSettled( + BUNDLED_FACES.map((face) => Promise.resolve().then(() => fontSet.load(face))), + ).then((results) => { + if (cancelled) return; + const loaded = results.some( + (r) => r.status === "fulfilled" && r.value.length > 0, + ); + if (!loaded) return; + + // A same-value assignment is a no-op in xterm, so bounce through a + // generic family to make it re-measure against the loaded face. + const family = term.options.fontFamily ?? TERMINAL_FONT_FAMILY; + term.options.fontFamily = "monospace"; + term.options.fontFamily = family; + fit(); + term.clearTextureAtlas(); + if (term.rows > 0) term.refresh(0, term.rows - 1); + }); + + return () => { + cancelled = true; + }; +} diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 035af07580..203c7ec9f8 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -43,6 +43,8 @@ class FakeTerminal { clearSelection() {} + clearTextureAtlas() {} + dispose() {} focus() {} @@ -579,6 +581,46 @@ describe("ChatPage side panel collapse", () => { // (that timer is set after `new WebSocket`). Without its own deadline the tab // strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed // coverage in src/components/ChatSidebar.test.tsx. +describe("ChatPage bundled font swap-in", () => { + it("redraws the terminal with the bundled font once it finishes loading", async () => { + let releaseFont!: () => void; + const fontGate = new Promise((resolve) => { + releaseFont = resolve; + }); + Object.defineProperty(document, "fonts", { + configurable: true, + value: { + check: () => false, + load: async () => { + await fontGate; + return [{}]; + }, + }, + }); + const clearAtlas = vi.spyOn(FakeTerminal.prototype, "clearTextureAtlas"); + try { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + expect(clearAtlas).not.toHaveBeenCalled(); + + await act(async () => { + releaseFont(); + await fontGate; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(clearAtlas).toHaveBeenCalledTimes(1); + } finally { + clearAtlas.mockRestore(); + delete (document as { fonts?: unknown }).fonts; + } + }); +}); + describe("ChatPage PTY ticket connect deadline", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 2a97574dfa..dafdde133f 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -94,6 +94,10 @@ import { type PtyBannerAction, } from "@/lib/pty-close-copy"; import { ptyAttachToken } from "@/lib/pty-attach-token"; +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "@/lib/terminal-font-refit"; import { loseWebglContexts } from "@/lib/xterm-webgl-release"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; @@ -579,8 +583,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const term = new Terminal({ allowProposedApi: true, cursorBlink: true, - fontFamily: - "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontFamily: TERMINAL_FONT_FAMILY, fontSize: terminalFontSizeForWidth(tierW0), lineHeight: terminalLineHeightForWidth(tierW0), letterSpacing: 0, @@ -1112,6 +1115,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }); }); + // The rAF fits above still measure the fallback font if JetBrains Mono + // hasn't swapped in yet (#92899). + const stopFontRefit = refitWhenTerminalFontLoads(term, syncTerminalMetrics); + // WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this // awaits a single-use ticket via /api/auth/ws-ticket before opening; // in loopback mode it resolves synchronously against the injected @@ -1607,6 +1614,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf); if (settleRaf1) cancelAnimationFrame(settleRaf1); if (settleRaf2) cancelAnimationFrame(settleRaf2); + stopFontRefit(); clearReconnectTimer(); clearConnectingTimer(); clearTicketTimer();