From 6f1d9aff9954c4feab075593d148e68a272c0b30 Mon Sep 17 00:00:00 2001 From: Danielalnajjar Date: Mon, 23 Mar 2026 11:00:04 -0700 Subject: [PATCH 1/4] feat(web): persist typography settings Persist local typography preferences in app settings so the UI and terminal can read a stable source of truth. Key files: - apps/web/src/appSettings.ts: adds ui and terminal font size/family settings to persisted app settings - apps/web/src/appSettings.test.ts: covers defaults and decoding for the new typography settings Design decisions: - Store typography alongside existing local app settings so the feature remains device-local. - Keep font family values as trimmed free-form strings so installed system fonts can be referenced directly. Unlocks root typography application and settings UI controls. Generated with OpenAI Codex --- apps/web/src/appSettings.test.ts | 6 ++++++ apps/web/src/appSettings.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537..867e2c06c 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, + DEFAULT_TERMINAL_FONT_SIZE, DEFAULT_TIMESTAMP_FORMAT, + DEFAULT_UI_FONT_SIZE, getAppModelOptions, getCustomModelOptionsByProvider, getCustomModelsByProvider, @@ -215,6 +217,10 @@ describe("AppSettingsSchema", () => { confirmThreadDelete: false, enableAssistantStreaming: false, timestampFormat: DEFAULT_TIMESTAMP_FORMAT, + uiFontSize: DEFAULT_UI_FONT_SIZE, + terminalFontSize: DEFAULT_TERMINAL_FONT_SIZE, + uiFontFamily: "", + monoFontFamily: "", customCodexModels: [], customClaudeModels: [], }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92..9380fd45b 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -13,10 +13,18 @@ import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const MAX_FONT_FAMILY_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const UiFontSize = Schema.Literals(["sm", "md", "lg"]); +export type UiFontSize = typeof UiFontSize.Type; +export const DEFAULT_UI_FONT_SIZE: UiFontSize = "md"; + +export const TerminalFontSize = Schema.Literals(["sm", "md", "lg", "xl"]); +export type TerminalFontSize = typeof TerminalFontSize.Type; +export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = "md"; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -53,6 +61,14 @@ export const AppSettingsSchema = Schema.Struct({ confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), + uiFontSize: UiFontSize.pipe(withDefaults(() => DEFAULT_UI_FONT_SIZE)), + terminalFontSize: TerminalFontSize.pipe(withDefaults(() => DEFAULT_TERMINAL_FONT_SIZE)), + uiFontFamily: Schema.String.check(Schema.isMaxLength(MAX_FONT_FAMILY_LENGTH)).pipe( + withDefaults(() => ""), + ), + monoFontFamily: Schema.String.check(Schema.isMaxLength(MAX_FONT_FAMILY_LENGTH)).pipe( + withDefaults(() => ""), + ), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), From d7427920414ab4f181fab7ed29c4dd7f7528dee8 Mon Sep 17 00:00:00 2001 From: Danielalnajjar Date: Mon, 23 Mar 2026 11:00:16 -0700 Subject: [PATCH 2/4] feat(web): apply typography preferences across UI Apply persisted typography settings to interface, code, and terminal surfaces through root-level CSS variables. Key files: - apps/web/src/appTypography.ts: maps settings to CSS variables and terminal typography helpers - apps/web/src/appTypography.test.ts: verifies size mappings and font normalization - apps/web/src/routes/__root.tsx: mounts typography application at app startup - apps/web/src/index.css: wires interface and code font variables into global styles Design decisions: - Use CSS custom properties at the root so typography updates propagate without component rewrites. - Keep regular UI inputs on the interface font while reserving the mono stack for code and terminal surfaces. Unlocks settings-driven typography changes across the app and terminal runtime. Generated with OpenAI Codex --- apps/web/src/appTypography.test.ts | 36 ++++++++++ apps/web/src/appTypography.ts | 106 +++++++++++++++++++++++++++++ apps/web/src/index.css | 30 +++++--- apps/web/src/routes/__root.tsx | 3 + 4 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/appTypography.test.ts create mode 100644 apps/web/src/appTypography.ts diff --git a/apps/web/src/appTypography.test.ts b/apps/web/src/appTypography.test.ts new file mode 100644 index 000000000..dc754e554 --- /dev/null +++ b/apps/web/src/appTypography.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + getTerminalFontSizePx, + getUiFontSizePx, + normalizeFontFamilyOverride, +} from "./appTypography"; + +describe("normalizeFontFamilyOverride", () => { + it("treats blank and whitespace-only values as no override", () => { + expect(normalizeFontFamilyOverride("")).toBeNull(); + expect(normalizeFontFamilyOverride(" ")).toBeNull(); + expect(normalizeFontFamilyOverride(undefined)).toBeNull(); + }); + + it("trims non-empty font family overrides", () => { + expect(normalizeFontFamilyOverride(" Inter, system-ui, sans-serif ")).toBe( + "Inter, system-ui, sans-serif", + ); + }); +}); + +describe("font size helpers", () => { + it("maps interface sizes to conservative root pixel values", () => { + expect(getUiFontSizePx("sm")).toBe(15); + expect(getUiFontSizePx("md")).toBe(16); + expect(getUiFontSizePx("lg")).toBe(17); + }); + + it("maps terminal sizes to xterm pixel values", () => { + expect(getTerminalFontSizePx("sm")).toBe(11); + expect(getTerminalFontSizePx("md")).toBe(12); + expect(getTerminalFontSizePx("lg")).toBe(13); + expect(getTerminalFontSizePx("xl")).toBe(14); + }); +}); diff --git a/apps/web/src/appTypography.ts b/apps/web/src/appTypography.ts new file mode 100644 index 000000000..059d5326c --- /dev/null +++ b/apps/web/src/appTypography.ts @@ -0,0 +1,106 @@ +import { useLayoutEffect } from "react"; +import { + DEFAULT_TERMINAL_FONT_SIZE, + type TerminalFontSize, + type UiFontSize, + useAppSettings, +} from "./appSettings"; + +export const UI_FONT_SIZE_OPTIONS: ReadonlyArray<{ value: UiFontSize; label: string }> = [ + { value: "sm", label: "Small" }, + { value: "md", label: "Default" }, + { value: "lg", label: "Large" }, +]; + +export const TERMINAL_FONT_SIZE_OPTIONS: ReadonlyArray<{ + value: TerminalFontSize; + label: string; +}> = [ + { value: "sm", label: "Small" }, + { value: "md", label: "Default" }, + { value: "lg", label: "Large" }, + { value: "xl", label: "Extra large" }, +]; + +const DEFAULT_MONO_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; + +const UI_FONT_SIZE_PX: Record = { + sm: 15, + md: 16, + lg: 17, +}; + +const TERMINAL_FONT_SIZE_PX: Record = { + sm: 11, + md: 12, + lg: 13, + xl: 14, +}; + +export function normalizeFontFamilyOverride(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +export function getUiFontSizePx(value: UiFontSize): number { + return UI_FONT_SIZE_PX[value]; +} + +export function getTerminalFontSizePx(value: TerminalFontSize): number { + return TERMINAL_FONT_SIZE_PX[value]; +} + +function setRootStyleProperty(name: string, value: string | null): void { + if (value === null) { + document.documentElement.style.removeProperty(name); + return; + } + + document.documentElement.style.setProperty(name, value); +} + +function parsePx(value: string, fallback: number): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function useAppTypography(): void { + const { settings } = useAppSettings(); + + useLayoutEffect(() => { + setRootStyleProperty("--app-ui-font-size", `${getUiFontSizePx(settings.uiFontSize)}px`); + setRootStyleProperty( + "--app-terminal-font-size", + `${getTerminalFontSizePx(settings.terminalFontSize)}px`, + ); + setRootStyleProperty( + "--app-ui-font-family", + normalizeFontFamilyOverride(settings.uiFontFamily), + ); + setRootStyleProperty( + "--app-mono-font-family", + normalizeFontFamilyOverride(settings.monoFontFamily), + ); + }, [ + settings.monoFontFamily, + settings.terminalFontSize, + settings.uiFontFamily, + settings.uiFontSize, + ]); +} + +export function readTerminalTypographyFromApp(): { fontFamily: string; fontSize: number } { + const rootStyles = getComputedStyle(document.documentElement); + const fontFamily = + rootStyles.getPropertyValue("--app-mono-font-family").trim() || DEFAULT_MONO_FONT_FAMILY; + const fontSize = parsePx( + rootStyles.getPropertyValue("--app-terminal-font-size"), + getTerminalFontSizePx(DEFAULT_TERMINAL_FONT_SIZE), + ); + + return { + fontFamily, + fontSize, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fa..aa37357f8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -29,6 +29,8 @@ --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); + --font-sans: var(--app-ui-font-family); + --font-mono: var(--app-mono-font-family); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -89,6 +91,12 @@ --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); + --app-ui-font-family: + "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --app-mono-font-family: + "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --app-ui-font-size: 16px; + --app-terminal-font-size: 12px; @variant dark { color-scheme: dark; @@ -120,14 +128,12 @@ } } +html { + font-size: var(--app-ui-font-size); +} + body { - font-family: - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: var(--app-ui-font-family); margin: 0; padding: 0; min-height: 100vh; @@ -157,10 +163,12 @@ body::after { } pre, -code, -textarea, -input { - font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +code { + font-family: var(--app-mono-font-family); +} + +.xterm .xterm-helper-textarea { + font-family: var(--app-mono-font-family); } /* Window drag region (frameless titlebar) */ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82..0d2d7b8c2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; +import { useAppTypography } from "../appTypography"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -36,6 +37,8 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + useAppTypography(); + if (!readNativeApi()) { return (
From 2c1f78ca8c197ec04d2787e30a2a776d2cb15727 Mon Sep 17 00:00:00 2001 From: Danielalnajjar Date: Mon, 23 Mar 2026 11:00:26 -0700 Subject: [PATCH 3/4] feat(web): add typography settings controls Expose typography settings in the Settings screen so users can adjust scale and local font stacks without editing config files. Key files: - apps/web/src/routes/_chat.settings.tsx: adds the Typography card, control wiring, defaults, and helper copy Design decisions: - Group UI scale and font stack controls under a single Typography card for discoverability. - Describe interface and code or terminal scopes separately so users can predict which surfaces each setting affects. Unlocks self-serve configuration for interface and terminal typography. Generated with OpenAI Codex --- apps/web/src/routes/_chat.settings.tsx | 167 ++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb..3a050f375 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -8,10 +8,16 @@ import { getCustomModelsForProvider, getDefaultCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, + MAX_FONT_FAMILY_LENGTH, MODEL_PROVIDER_SETTINGS, patchCustomModels, useAppSettings, } from "../appSettings"; +import { + normalizeFontFamilyOverride, + TERMINAL_FONT_SIZE_OPTIONS, + UI_FONT_SIZE_OPTIONS, +} from "../appTypography"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -85,6 +91,19 @@ function SettingsRouteView() { (option) => option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL), )?.name ?? settings.textGenerationModel; + const selectedUiFontSizeLabel = + UI_FONT_SIZE_OPTIONS.find((option) => option.value === settings.uiFontSize)?.label ?? + settings.uiFontSize; + const selectedTerminalFontSizeLabel = + TERMINAL_FONT_SIZE_OPTIONS.find((option) => option.value === settings.terminalFontSize) + ?.label ?? settings.terminalFontSize; + const hasTypographyOverrides = + settings.uiFontSize !== defaults.uiFontSize || + settings.terminalFontSize !== defaults.terminalFontSize || + (normalizeFontFamilyOverride(settings.uiFontFamily) ?? "") !== + (normalizeFontFamilyOverride(defaults.uiFontFamily) ?? "") || + (normalizeFontFamilyOverride(settings.monoFontFamily) ?? "") !== + (normalizeFontFamilyOverride(defaults.monoFontFamily) ?? ""); const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -240,8 +259,8 @@ function SettingsRouteView() {

Timestamp format

- System default follows your browser or OS time format. 12-hour{" "} - and 24-hour force the hour cycle. + System default follows your browser or OS time format. "12-hour" and "24-hour" + force the hour cycle.

{ + if (value !== "sm" && value !== "md" && value !== "lg") return; + updateSettings({ + uiFontSize: value, + }); + }} + > + + {selectedUiFontSizeLabel} + + + {UI_FONT_SIZE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ +
+
+

Terminal font size

+

+ Changes text size only in the built-in terminal drawer. +

+
+ +
+ + + + + + {hasTypographyOverrides ? ( +
+ +
+ ) : null} + + +

Models

From d388c2163566f4b13e8e87b0df712beee3c7d307 Mon Sep 17 00:00:00 2001 From: Danielalnajjar Date: Mon, 23 Mar 2026 11:00:34 -0700 Subject: [PATCH 4/4] feat(web): sync terminal typography live Update the integrated terminal drawer when typography settings change so users can preview terminal adjustments immediately. Key files: - apps/web/src/components/ThreadTerminalDrawer.tsx: reads terminal typography, updates xterm options, and resyncs geometry - apps/web/src/components/ThreadTerminalDrawer.test.ts: covers appearance updates and backend resize syncing Design decisions: - Update xterm appearance in place instead of recreating terminals to preserve scrollback and session state. - Refit and report terminal geometry after font changes so backend dimensions stay accurate. Unlocks live terminal typography previews from the settings screen. Generated with OpenAI Codex --- .../components/ThreadTerminalDrawer.test.ts | 122 ++++++++++- .../src/components/ThreadTerminalDrawer.tsx | 197 +++++++++++++----- 2 files changed, 261 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 4d5958377..428b7083e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; import { + applyTerminalAppearanceUpdate, resolveTerminalSelectionActionPosition, shouldHandleTerminalSelectionMouseUp, + syncTerminalGeometryWithBackend, terminalSelectionActionDelayForClickCount, } from "./ThreadTerminalDrawer"; @@ -73,3 +76,120 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); }); + +describe("applyTerminalAppearanceUpdate", () => { + it("refreshes in place when only the theme changes", () => { + const refresh = vi.fn(); + const terminal = { + options: { + theme: { background: "#000000" }, + fontFamily: "Menlo, monospace", + fontSize: 12, + }, + rows: 30, + refresh, + }; + + const result = applyTerminalAppearanceUpdate({ + terminal, + theme: { background: "#ffffff" }, + typography: { fontFamily: "Menlo, monospace", fontSize: 12 }, + }); + + expect(result).toBe("refresh"); + expect(terminal.options.theme).toEqual({ background: "#ffffff" }); + expect(refresh).toHaveBeenCalledWith(0, 29); + }); + + it("requests geometry sync when the terminal font changes", () => { + const refresh = vi.fn(); + const terminal = { + options: { + theme: { background: "#000000" }, + fontFamily: "Menlo, monospace", + fontSize: 12, + }, + rows: 30, + refresh, + }; + + const result = applyTerminalAppearanceUpdate({ + terminal, + theme: { background: "#000000" }, + typography: { fontFamily: '"SF Mono", Menlo, monospace', fontSize: 14 }, + }); + + expect(result).toBe("geometry"); + expect(terminal.options.fontFamily).toBe('"SF Mono", Menlo, monospace'); + expect(terminal.options.fontSize).toBe(14); + expect(refresh).not.toHaveBeenCalled(); + }); +}); + +describe("syncTerminalGeometryWithBackend", () => { + it("uses post-fit geometry and keeps the terminal pinned to the bottom when already at the bottom", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const resize = vi.fn().mockResolvedValue(undefined); + const scrollToBottom = vi.fn(); + const terminal = { + buffer: { active: { viewportY: 12, baseY: 12 } }, + cols: 80, + rows: 24, + scrollToBottom, + }; + const fitAddon = { + fit: vi.fn(() => { + terminal.cols = 120; + terminal.rows = 40; + }), + }; + + syncTerminalGeometryWithBackend({ + api: { terminal: { resize } }, + terminal, + fitAddon, + threadId, + terminalId: "default", + }); + + expect(fitAddon.fit).toHaveBeenCalledTimes(1); + expect(scrollToBottom).toHaveBeenCalledTimes(1); + expect(resize).toHaveBeenCalledWith({ + threadId, + terminalId: "default", + cols: 120, + rows: 40, + }); + }); + + it("does not force-scroll when the terminal is not at the bottom", () => { + const threadId = ThreadId.makeUnsafe("thread-2"); + const resize = vi.fn().mockResolvedValue(undefined); + const scrollToBottom = vi.fn(); + const terminal = { + buffer: { active: { viewportY: 4, baseY: 12 } }, + cols: 90, + rows: 30, + scrollToBottom, + }; + const fitAddon = { + fit: vi.fn(), + }; + + syncTerminalGeometryWithBackend({ + api: { terminal: { resize } }, + terminal, + fitAddon, + threadId, + terminalId: "secondary", + }); + + expect(scrollToBottom).not.toHaveBeenCalled(); + expect(resize).toHaveBeenCalledWith({ + threadId, + terminalId: "secondary", + cols: 90, + rows: 30, + }); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1bdbfb6ad..1424dd26a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; +import { readTerminalTypographyFromApp } from "../appTypography"; import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, @@ -109,6 +110,87 @@ function terminalThemeFromApp(): ITheme { }; } +interface TerminalGeometrySyncApi { + terminal: { + resize(args: { + threadId: ThreadId; + terminalId: string; + cols: number; + rows: number; + }): Promise; + }; +} + +interface TerminalGeometrySyncTerminal { + buffer: { + active: { + viewportY: number; + baseY: number; + }; + }; + cols: number; + rows: number; + scrollToBottom(): void; +} + +interface TerminalGeometrySyncFitAddon { + fit(): void; +} + +export function syncTerminalGeometryWithBackend(options: { + api: TerminalGeometrySyncApi; + terminal: TerminalGeometrySyncTerminal; + fitAddon: TerminalGeometrySyncFitAddon; + threadId: ThreadId; + terminalId: string; +}): void { + const { api, terminal, fitAddon, threadId, terminalId } = options; + const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + fitAddon.fit(); + if (wasAtBottom) { + terminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: terminal.cols, + rows: terminal.rows, + }) + .catch(() => undefined); +} + +interface TerminalAppearanceUpdateTerminal { + options: { + theme?: ITheme | undefined; + fontFamily?: string | undefined; + fontSize?: number | undefined; + }; + rows: number; + refresh(start: number, end: number): void; +} + +export function applyTerminalAppearanceUpdate(options: { + terminal: TerminalAppearanceUpdateTerminal; + theme: ITheme; + typography: { fontFamily: string; fontSize: number }; +}): "refresh" | "geometry" { + const { terminal, theme, typography } = options; + terminal.options.theme = theme; + + const fontFamilyChanged = terminal.options.fontFamily !== typography.fontFamily; + const fontSizeChanged = terminal.options.fontSize !== typography.fontSize; + + if (!fontFamilyChanged && !fontSizeChanged) { + terminal.refresh(0, Math.max(terminal.rows - 1, 0)); + return "refresh"; + } + + terminal.options.fontFamily = typography.fontFamily; + terminal.options.fontSize = typography.fontSize; + return "geometry"; +} + function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { @@ -190,8 +272,6 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; - resizeEpoch: number; - drawerHeight: number; } function TerminalViewport({ @@ -204,8 +284,6 @@ function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, - resizeEpoch, - drawerHeight, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -238,13 +316,14 @@ function TerminalViewport({ let disposed = false; + const terminalTypography = readTerminalTypographyFromApp(); const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, - fontSize: 12, + fontSize: terminalTypography.fontSize, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: terminalTypography.fontFamily, theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); @@ -256,6 +335,27 @@ function TerminalViewport({ const api = readNativeApi(); if (!api) return; + let geometrySyncFrame: number | null = null; + + const scheduleGeometrySync = () => { + if (geometrySyncFrame !== null) { + return; + } + + geometrySyncFrame = window.requestAnimationFrame(() => { + geometrySyncFrame = null; + const latestTerminal = terminalRef.current; + const latestFitAddon = fitAddonRef.current; + if (!latestTerminal || !latestFitAddon) return; + syncTerminalGeometryWithBackend({ + api, + terminal: latestTerminal, + fitAddon: latestFitAddon, + threadId, + terminalId, + }); + }); + }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -458,16 +558,33 @@ function TerminalViewport({ window.addEventListener("mouseup", handleMouseUp); mount.addEventListener("pointerdown", handlePointerDown); - const themeObserver = new MutationObserver(() => { + const syncTerminalAppearance = () => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; - activeTerminal.options.theme = terminalThemeFromApp(); - activeTerminal.refresh(0, activeTerminal.rows - 1); - }); - themeObserver.observe(document.documentElement, { + + const action = applyTerminalAppearanceUpdate({ + terminal: activeTerminal, + theme: terminalThemeFromApp(), + typography: readTerminalTypographyFromApp(), + }); + + if (action === "geometry") { + scheduleGeometrySync(); + } + }; + + const appearanceObserver = new MutationObserver(syncTerminalAppearance); + appearanceObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }); + let containerResizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + containerResizeObserver = new ResizeObserver(() => { + scheduleGeometrySync(); + }); + containerResizeObserver.observe(mount); + } const openTerminal = async () => { try { @@ -563,20 +680,13 @@ function TerminalViewport({ const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - activeFitAddon.fit(); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + syncTerminalGeometryWithBackend({ + api, + terminal: activeTerminal, + fitAddon: activeFitAddon, + threadId, + terminalId, + }); }, 30); void openTerminal(); @@ -592,7 +702,11 @@ function TerminalViewport({ } window.removeEventListener("mouseup", handleMouseUp); mount.removeEventListener("pointerdown", handlePointerDown); - themeObserver.disconnect(); + if (geometrySyncFrame !== null) { + window.cancelAnimationFrame(geometrySyncFrame); + } + appearanceObserver.disconnect(); + containerResizeObserver?.disconnect(); terminalRef.current = null; fitAddonRef.current = null; terminal.dispose(); @@ -614,30 +728,6 @@ function TerminalViewport({ }; }, [autoFocus, focusRequestId]); - useEffect(() => { - const api = readNativeApi(); - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - if (!api || !terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; - const frame = window.requestAnimationFrame(() => { - fitAddon.fit(); - if (wasAtBottom) { - terminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: terminal.cols, - rows: terminal.rows, - }) - .catch(() => undefined); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); return (
); @@ -714,7 +804,6 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); - const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); const onHeightChangeRef = useRef(onHeightChange); @@ -905,7 +994,6 @@ export default function ThreadTerminalDrawer({ return; } syncHeight(drawerHeightRef.current); - setResizeEpoch((value) => value + 1); }, [syncHeight], ); @@ -921,7 +1009,6 @@ export default function ThreadTerminalDrawer({ if (!resizeStateRef.current) { syncHeight(clampedHeight); } - setResizeEpoch((value) => value + 1); }; window.addEventListener("resize", onWindowResize); return () => { @@ -1015,8 +1102,6 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} />
@@ -1035,8 +1120,6 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} /> )}