diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 33d9eded..a047677d 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -2,6 +2,8 @@ import React from 'react'; import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import { cn } from '@/lib/utils'; +import { loadMonoFont } from '@/lib/fontLoader'; +import type { MonoFontOption } from '@/lib/fontOptions'; import type { TerminalTheme } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import { @@ -27,20 +29,24 @@ const loadGhostty = (): Promise => ghostty: await module.Ghostty.load(), })); -// The web entry defers its ~2 MB Nerd Font download until a terminal actually -// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for -// it with a short bound so a cached font is in place before the glyph atlas is -// built, while a cold CDN fetch never blocks the terminal from opening; the -// runtimes without the hook (VS Code, mobile) resolve immediately. -const NERD_FONT_WAIT_MS = 2000; -const ensureNerdFonts = (): Promise => { - if (typeof window === 'undefined') return Promise.resolve(); - const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise }).__openchamberEnsureNerdFonts; - if (typeof loader !== 'function') return Promise.resolve(); - return Promise.race([ - Promise.resolve(loader()).catch(() => undefined), - new Promise((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)), - ]).then(() => undefined); +// Wait briefly for both the selected mono font and the web entry's deferred +// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block +// opening the terminal, so the renderer starts after the bound and is rebuilt +// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once. +const TERMINAL_FONT_WAIT_MS = 2000; +const loadNerdFonts = (): Promise => + Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined); + +const waitForTerminalFonts = (font: MonoFontOption) => { + const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined); + const loadedBeforeTimeout = new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS); + void loaded.then(() => { + clearTimeout(timeout); + resolve(true); + }); + }); + return { loaded, loadedBeforeTimeout }; }; type TerminalSize = { cols: number; rows: number }; @@ -91,6 +97,7 @@ type Props = { onInput: (data: string) => void; onResize: (cols: number, rows: number) => void; theme: TerminalTheme; + monoFont: MonoFontOption; fontFamily: string; fontSize: number; className?: string; @@ -100,7 +107,7 @@ type Props = { }; const TerminalViewport = React.forwardRef(({ - sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, + sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className, enableTouchScroll = false, autoFocus = true, isVisible = true, }, ref) => { const containerRef = React.useRef(null); @@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef(({ window.addEventListener('focus', handleWindowFocus); window.addEventListener('blur', handleWindowBlur); - Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => { + const fonts = waitForTerminalFonts(monoFont); + Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => { if (disposed) return; terminal = new module.Terminal({ ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), @@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef(({ const safeReset = safeResetRef.current; if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); fitFrame = requestAnimationFrame(fit); + if (!fontsLoaded) { + void fonts.loaded.then(() => { + if (!disposed && terminalRef.current === terminal) { + setRendererGeneration((value) => value + 1); + } + }); + } }); return () => { @@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef(({ writeEpochRef.current += 1; rendererReadyRef.current = false; }; - }, [fit, fontFamily, fontSize, rendererGeneration, theme]); + }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]); React.useEffect(() => { const terminal = terminalRef.current; diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 8e3e83f7..5e25ca0b 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1140,6 +1140,7 @@ export const TerminalView: React.FC = ({ visible }) => { onInput={handleViewportInput} onResize={handleViewportResize} theme={xtermTheme} + monoFont={monoFont} fontFamily={resolvedFontStack} fontSize={terminalFontSize} enableTouchScroll={useTouchTerminalInput} diff --git a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts index d03379c1..1de0e9a8 100644 --- a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts +++ b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts @@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => { expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)'); expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})'); }); + + test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => { + expect(terminalViewportSource).toContain('loadMonoFont(font)'); + expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])'); + expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])'); + expect(terminalViewportSource).toContain('if (!fontsLoaded)'); + expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {'); + expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)'); + }); }); diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts index 2e3fa7b4..0c853341 100644 --- a/packages/ui/src/vite-env.d.ts +++ b/packages/ui/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface Window { + __openchamberEnsureNerdFonts?: () => Promise; __opencodeDebug?: { getLastAssistantMessage: () => unknown; getAllMessages: (truncate?: boolean) => unknown[];