diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts
index 8467c13c2ce..5c642e33f0f 100644
--- a/apps/web/src/appearanceFonts.test.ts
+++ b/apps/web/src/appearanceFonts.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import {
+ areFontAdvancesMonospace,
clampCodeFontSize,
clampInterfaceFontSize,
clampPromptFontSize,
@@ -9,8 +10,22 @@ import {
appearanceFontStack,
cssFontFamilies,
resolveDefaultFamilyLabel,
+ resolveTerminalFontPreference,
} from "./appearanceFonts";
+describe("areFontAdvancesMonospace", () => {
+ it("accepts a fixed advance and rejects any proportional glyph", () => {
+ expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true);
+ expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false);
+ expect(areFontAdvancesMonospace([10, 10.02])).toBe(false);
+ });
+
+ it("fails open when canvas metrics are unavailable", () => {
+ expect(areFontAdvancesMonospace([])).toBe(true);
+ expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true);
+ });
+});
+
describe("cssFontFamilies", () => {
it("returns null for effectively empty input", () => {
expect(cssFontFamilies("")).toBeNull();
@@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {
});
});
+describe("resolveTerminalFontPreference", () => {
+ it("inherits the code font in simple mode", () => {
+ expect(
+ resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }),
+ ).toBe("Fira Code");
+ expect(
+ resolveTerminalFontPreference({
+ advanced: false,
+ code: "Fira Code",
+ terminal: "Berkeley Mono",
+ }),
+ ).toBe("Fira Code");
+ });
+
+ it("keeps code and terminal fonts independent in advanced mode", () => {
+ expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe(
+ "",
+ );
+ expect(
+ resolveTerminalFontPreference({
+ advanced: true,
+ code: "Fira Code",
+ terminal: "Berkeley Mono",
+ }),
+ ).toBe("Berkeley Mono");
+ });
+});
+
describe("font size clamping", () => {
it("keeps sizes inside the ranges the UI can absorb", () => {
expect(clampInterfaceFontSize(16)).toBe(16);
diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts
index 3fb6c821a1b..60801ef0118 100644
--- a/apps/web/src/appearanceFonts.ts
+++ b/apps/web/src/appearanceFonts.ts
@@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =
export const DEFAULT_CODE_FONT_STACK =
'"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
+export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
+
+/**
+ * Simple typography treats the terminal as another monospace surface. In
+ * Advanced mode an empty terminal preference means the terminal default,
+ * keeping later code-font changes isolated to code surfaces.
+ */
+export function resolveTerminalFontPreference(input: {
+ readonly advanced: boolean;
+ readonly code: string;
+ readonly terminal: string;
+}): string {
+ if (input.advanced) return input.terminal;
+ return input.code;
+}
+
function quoteFontFamilyName(name: string): string {
const bare = name.trim();
if (bare.length === 0) return "";
@@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean {
}
}
+const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
+const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
+const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
+
+export function areFontAdvancesMonospace(advances: readonly number[]): boolean {
+ const reference = advances[0];
+ if (
+ reference === undefined ||
+ reference <= 0 ||
+ advances.some((advance) => !Number.isFinite(advance) || advance <= 0)
+ ) {
+ return true;
+ }
+ return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);
+}
+
/**
* Whether a family renders every character on the same advance. Cell-grid
* surfaces (the terminal) require this: a proportional face draws its text
@@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean {
fontProbeContext = document.createElement("canvas").getContext("2d");
}
if (fontProbeContext === null) return true;
+ const context = fontProbeContext;
// Fall back to a generic mono so an absent face measures as monospace and
// is left for the normal fallback chain to resolve.
- fontProbeContext.font = `32px ${families}, monospace`;
- const narrow = fontProbeContext.measureText("i").width;
- const wide = fontProbeContext.measureText("M").width;
- if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true;
- return Math.abs(wide - narrow) < 0.5;
+ for (const variant of MONOSPACE_PROBE_VARIANTS) {
+ context.font = `${variant} 32px ${families}, monospace`;
+ const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
+ if (!areFontAdvancesMonospace(advances)) return false;
+ }
+ return true;
} catch {
return true;
}
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index 914e04b647d..072241426e2 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -18,6 +18,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
+import * as Schema from "effect/Schema";
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
@@ -57,6 +58,7 @@ import {
} from "../types";
import { readLocalApi } from "~/localApi";
import { useClientSettings } from "../hooks/useSettings";
+import { useLocalStorage } from "../hooks/useLocalStorage";
import { useAttachedTerminalSession } from "../state/terminalSessions";
import { serverEnvironment } from "../state/server";
import { previewEnvironment } from "../state/preview";
@@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal";
import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
import { useAtomCommand } from "../state/use-atom-command";
import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
+import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
@@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(
}
interface TerminalViewportProps {
+ advancedTypography: boolean;
threadRef: ScopedThreadRef;
threadId: ThreadId;
terminalId: string;
@@ -264,6 +268,7 @@ interface TerminalLaunchLocation {
}
export function TerminalViewport({
+ advancedTypography,
threadRef,
threadId,
terminalId,
@@ -312,10 +317,12 @@ export function TerminalViewport({
onAddTerminalContext(selection);
});
const readTerminalLabel = useEffectEvent(() => terminalLabel);
- // The terminal inherits the monospace (code) preference unless it has an
- // override of its own, so one font choice drives every mono surface.
- const terminalFontFamily = useClientSettings(
- (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode,
+ const terminalFontFamily = useClientSettings((settings) =>
+ resolveTerminalFontPreference({
+ advanced: advancedTypography,
+ code: settings.fontFamilyCode,
+ terminal: settings.fontFamilyTerminal,
+ }),
);
const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);
const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
@@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({
terminalLaunchLocationsById,
}: ThreadTerminalDrawerProps) {
const isPanel = mode === "panel";
+ const [advancedTypography] = useLocalStorage(
+ TYPOGRAPHY_ADVANCED_STORAGE_KEY,
+ false,
+ Schema.Boolean,
+ );
const controlledDrawerHeight = clampDrawerHeight(height);
const [drawerHeightState, setDrawerHeightState] = useState(() => ({
threadId,
@@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({
>
updateSettings({ fontFamilyTerminal })}
requireMonospace
@@ -1258,7 +1258,11 @@ function TerminalFontRow() {
}}
preview={
}
@@ -1350,7 +1354,11 @@ function SimpleFontRows() {
<>
>
@@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([
: []),
]);
-const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
-
/**
* The two-font view by default - one sans, one monospace, each cascading to
* every surface it reaches - with an Advanced switch in the section header
@@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
* target exists to scroll to.
*/
function TypographySection() {
- const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean);
+ const [advanced, setAdvanced] = useLocalStorage(
+ TYPOGRAPHY_ADVANCED_STORAGE_KEY,
+ false,
+ Schema.Boolean,
+ );
const searchTargetId = useSettingsSearchTargetId();
// Flip Advanced on once per search jump so the hidden target can mount and
// scroll; tracking the handled id lets the user turn it back off without
diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts
index f1559ac1578..d20a6cfe005 100644
--- a/apps/web/src/terminal/ghostty/surface.test.ts
+++ b/apps/web/src/terminal/ghostty/surface.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vite-plus/test";
+import { describe, expect, it, vi } from "vite-plus/test";
import type { GhosttyCell, GhosttyRow } from "./core";
import {
@@ -11,6 +11,7 @@ import {
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
+ loadTerminalFontFamily,
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
shouldShowTerminalLinkHover,
@@ -295,6 +296,27 @@ describe("application mouse reporting", () => {
});
describe("terminal font resolution", () => {
+ it("validates the requested face after its styles load", async () => {
+ let loaded = false;
+ const load = vi.fn(async () => {
+ loaded = true;
+ return [];
+ });
+ const resolve = vi.fn(() => {
+ expect(loaded).toBe(true);
+ return DEFAULT_TERMINAL_FONT_FAMILY;
+ });
+
+ await expect(
+ loadTerminalFontFamily("Proportional Test", 12, {
+ load,
+ resolve,
+ }),
+ ).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY);
+ expect(load).toHaveBeenCalledTimes(4);
+ expect(resolve).toHaveBeenCalledWith("Proportional Test");
+ });
+
it("keeps the glyph fallbacks behind a custom text face", () => {
expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts
index 853da9c139c..8ff92c01c0c 100644
--- a/apps/web/src/terminal/ghostty/surface.ts
+++ b/apps/web/src/terminal/ghostty/surface.ts
@@ -36,6 +36,13 @@ const CONTENT_PADDING = 4;
const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
/** Half a blink cycle: the visible and hidden phases are equally long. */
const CURSOR_BLINK_INTERVAL_MS = 500;
+const TERMINAL_FONT_LOAD_TEXT = "iMW0@# .";
+const TERMINAL_FONT_LOAD_VARIANTS = [
+ "normal 400",
+ "normal 700",
+ "italic 400",
+ "italic 700",
+] as const;
/** Requested terminal font; omitted fields fall back to the defaults. */
export interface GhosttyTerminalFont {
@@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string {
.join(", ");
}
+function uncheckedTerminalFontFamily(family?: string): string {
+ const custom = family === undefined ? "" : quoteTerminalFontFamilies(family);
+ return custom.length === 0
+ ? DEFAULT_TERMINAL_FONT_FAMILY
+ : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
+}
+
export function terminalFontFamily(family?: string): string {
// Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes
// the whole canvas font string invalid and the assignment silently no-ops.
@@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string {
// it here rather than render a ragged grid with a stranded cursor.
if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY;
// A custom face keeps the glyph fallbacks so prompt symbols stay covered.
- return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
+ return uncheckedTerminalFontFamily(custom);
+}
+
+/** Load every style the renderer can request, then validate the actual face. */
+export async function loadTerminalFontFamily(
+ family: string | undefined,
+ size: number,
+ environment?: {
+ readonly load: (font: string, text: string) => Promise;
+ readonly resolve: (family: string | undefined) => string;
+ },
+): Promise {
+ const candidate = uncheckedTerminalFontFamily(family);
+ const load =
+ environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text));
+ try {
+ await Promise.all(
+ TERMINAL_FONT_LOAD_VARIANTS.map((variant) =>
+ load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT),
+ ),
+ );
+ } catch {
+ // The fixed-width fallback stack remains available if a face cannot load.
+ }
+ return (environment?.resolve ?? terminalFontFamily)(family);
}
/**
@@ -479,9 +517,11 @@ export class GhosttyTerminalSurface {
private readonly options: GhosttyTerminalSurfaceOptions;
private metrics: GhosttyCellMetrics;
private fontFamily: string;
+ private requestedFontFamily: string | undefined;
private fontSize: number;
private requestedFontSize: number;
private fontEpoch = 0;
+ private pendingFontEpoch: number | null = null;
private readonly resizeObserver: ResizeObserver;
private readonly scrollbarThumb: HTMLDivElement;
private snapshot: GhosttySnapshot | null = null;
@@ -545,6 +585,7 @@ export class GhosttyTerminalSurface {
context: CanvasRenderingContext2D,
core: GhosttyTerminalCore,
metrics: GhosttyCellMetrics,
+ fontFamily: string,
options: GhosttyTerminalSurfaceOptions,
) {
this.mount = mount;
@@ -557,7 +598,8 @@ export class GhosttyTerminalSurface {
this.metrics = metrics;
this.options = options;
this.theme = options.theme;
- this.fontFamily = terminalFontFamily(options.font?.family);
+ this.fontFamily = fontFamily;
+ this.requestedFontFamily = options.font?.family;
this.fontSize = terminalFontSize(options.font?.size);
this.requestedFontSize = this.fontSize;
this.resizeObserver = new ResizeObserver(() => this.fit());
@@ -600,16 +642,15 @@ export class GhosttyTerminalSurface {
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("Canvas 2D is unavailable");
- const fontFamily = terminalFontFamily(options.font?.family);
const fontSize = terminalFontSize(options.font?.size);
try {
// Cell metrics must come from the faces that will render; measuring before
// the bundled webfonts load would size the grid from a fallback font.
await ensureTerminalSymbolsFont();
- await document.fonts.load(`${fontSize}px ${fontFamily}`);
} catch {
// Metrics fall back to whichever faces are already available.
}
+ const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize);
const metrics = measureGhosttyCell(context, fontSize, fontFamily);
const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);
const core = await GhosttyTerminalCore.create(
@@ -629,6 +670,7 @@ export class GhosttyTerminalSurface {
context,
core,
metrics,
+ fontFamily,
options,
);
surface.fit();
@@ -667,18 +709,16 @@ export class GhosttyTerminalSurface {
async setFont(font: GhosttyTerminalFont): Promise {
if (this.disposed) return;
- const fontFamily = terminalFontFamily(font.family);
const fontSize = terminalFontSize(font.size);
// The fields only change together with their metrics after the load, and
// the epoch lets the newest overlapping call win regardless of load order.
const epoch = ++this.fontEpoch;
- try {
- await document.fonts.load(`${fontSize}px ${fontFamily}`);
- } catch {
- // Metrics fall back to whichever faces are already available.
- }
+ this.pendingFontEpoch = epoch;
+ const fontFamily = await loadTerminalFontFamily(font.family, fontSize);
if (this.disposed || epoch !== this.fontEpoch) return;
+ this.pendingFontEpoch = null;
this.fontFamily = fontFamily;
+ this.requestedFontFamily = font.family;
this.requestedFontSize = fontSize;
this.fontSize = fontSize;
this.applyFontMetrics();
@@ -706,6 +746,17 @@ export class GhosttyTerminalSurface {
private readonly onFontsLoaded = () => {
if (this.disposed) return;
+ // The explicit load validates every style and applies the newest request.
+ // Its own loading events must not revalidate the previously applied face.
+ if (this.pendingFontEpoch !== null) return;
+ // A face may become available after an earlier fallback measurement. Run
+ // the fixed-width guard again before using its newly loaded metrics.
+ const fontFamily = terminalFontFamily(this.requestedFontFamily);
+ if (fontFamily !== this.fontFamily) {
+ this.fontFamily = fontFamily;
+ this.applyFontMetrics();
+ return;
+ }
// A face that finished loading after the initial measurement changes glyph
// advances; re-measure and refit so the grid matches what actually renders.
const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);