From 8c1aa58450648d2ce656daf176224f5f6070a4bb Mon Sep 17 00:00:00 2001 From: Paul Opmann Date: Mon, 30 Mar 2026 12:29:45 +0300 Subject: [PATCH 1/4] add support for changing terminal font from settings --- ....ts => ThreadTerminalDrawer.logic.test.ts} | 26 +++++++++- .../src/components/ThreadTerminalDrawer.tsx | 39 +++++++++++++- .../components/settings/SettingsPanels.tsx | 34 ++++++++++++ apps/web/src/hooks/useSettings.test.ts | 21 ++++++++ apps/web/src/hooks/useSettings.ts | 34 ++++++++++-- packages/contracts/src/settings.test.ts | 52 +++++++++++++++++++ packages/contracts/src/settings.ts | 19 +++++++ 7 files changed, 218 insertions(+), 7 deletions(-) rename apps/web/src/components/{ThreadTerminalDrawer.test.ts => ThreadTerminalDrawer.logic.test.ts} (77%) create mode 100644 packages/contracts/src/settings.test.ts diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.logic.test.ts similarity index 77% rename from apps/web/src/components/ThreadTerminalDrawer.test.ts rename to apps/web/src/components/ThreadTerminalDrawer.logic.test.ts index 4d59583777b..b7befa378be 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.logic.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + applyTerminalFontFamily, resolveTerminalSelectionActionPosition, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, @@ -72,4 +73,27 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); + + it("applies terminal font changes with a reflow refresh", () => { + const terminal = { + options: { + fontFamily: '"SF Mono", monospace', + }, + rows: 24, + refresh: vi.fn(), + }; + const fitAddon = { + fit: vi.fn(), + }; + + applyTerminalFontFamily( + terminal, + fitAddon, + '"MesloLGS NF", "JetBrainsMono Nerd Font", monospace', + ); + + expect(terminal.options.fontFamily).toBe('"MesloLGS NF", "JetBrainsMono Nerd Font", monospace'); + expect(fitAddon.fit).toHaveBeenCalledTimes(1); + expect(terminal.refresh).toHaveBeenCalledWith(0, 23); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1bdbfb6ad60..3ce8cf6e73d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -26,6 +26,7 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; +import { useSettings } from "../hooks/useSettings"; import { readNativeApi } from "~/nativeApi"; const MIN_DRAWER_HEIGHT = 180; @@ -180,6 +181,28 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +interface TerminalFontTarget { + options: { + fontFamily?: string; + }; + rows: number; + refresh: (start: number, end: number) => void; +} + +interface TerminalFitTarget { + fit: () => void; +} + +export function applyTerminalFontFamily( + terminal: TerminalFontTarget, + fitAddon: TerminalFitTarget, + fontFamily: string, +): void { + terminal.options.fontFamily = fontFamily; + fitAddon.fit(); + terminal.refresh(0, Math.max(terminal.rows - 1, 0)); +} + interface TerminalViewportProps { threadId: ThreadId; terminalId: string; @@ -207,6 +230,8 @@ function TerminalViewport({ resizeEpoch, drawerHeight, }: TerminalViewportProps) { + const settings = useSettings(); + const terminalFontFamily = settings.terminalFontFamily; const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -244,7 +269,7 @@ function TerminalViewport({ lineHeight: 1.2, fontSize: 12, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: terminalFontFamily, theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); @@ -602,6 +627,18 @@ function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, runtimeEnv, terminalId, threadId]); + useEffect(() => { + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!terminal || !fitAddon) return; + const frame = window.requestAnimationFrame(() => { + applyTerminalFontFamily(terminal, fitAddon, terminalFontFamily); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [terminalFontFamily]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f68..fad9351be93 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -463,6 +463,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), + ...(settings.terminalFontFamily !== DEFAULT_UNIFIED_SETTINGS.terminalFontFamily + ? ["Terminal font"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -486,6 +489,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.terminalFontFamily, settings.timestampFormat, theme, ], @@ -838,6 +842,36 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + terminalFontFamily: event.target.value, + }) + } + /> + } + /> + { @@ -13,4 +14,24 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { confirmThreadDelete: false, }); }); + + it("migrates the terminal font family from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + terminalFontFamily: ' "MesloLGS NF", monospace ', + }), + ).toEqual({ + terminalFontFamily: '"MesloLGS NF", monospace', + }); + }); + + it("falls back to the default terminal font when the legacy value is blank", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + terminalFontFamily: "", + }), + ).toEqual({ + terminalFontFamily: DEFAULT_TERMINAL_FONT_FAMILY, + }); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f804bc48b8..a8858f4ae1f 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -22,8 +22,10 @@ import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { type ClientSettings, ClientSettingsSchema, + DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, + normalizeTerminalFontFamily, SidebarProjectSortOrder, SidebarThreadSortOrder, TimestampFormat, @@ -63,6 +65,20 @@ function splitPatch(patch: Partial): { }; } +function normalizeClientPatch(clientPatch: Partial): Partial { + if (!Object.hasOwn(clientPatch, "terminalFontFamily")) { + return clientPatch; + } + + return { + ...clientPatch, + terminalFontFamily: + typeof clientPatch.terminalFontFamily === "string" + ? normalizeTerminalFontFamily(clientPatch.terminalFontFamily) + : clientPatch.terminalFontFamily, + }; +} + // ── Hooks ──────────────────────────────────────────────────────────── /** @@ -123,7 +139,8 @@ export function useUpdateSettings() { } if (Object.keys(clientPatch).length > 0) { - setClientSettings((prev) => ({ ...prev, ...clientPatch })); + const normalizedClientPatch = normalizeClientPatch(clientPatch); + setClientSettings((prev) => ({ ...prev, ...normalizedClientPatch })); } }, [queryClient, setClientSettings], @@ -226,6 +243,12 @@ export function buildLegacyClientSettingsMigrationPatch( patch.timestampFormat = legacySettings.timestampFormat; } + if (Predicate.isString(legacySettings.terminalFontFamily)) { + patch.terminalFontFamily = normalizeTerminalFontFamily( + legacySettings.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY, + ); + } + return patch; } @@ -256,10 +279,11 @@ export function migrateLocalSettingsToServer(): void { if (Object.keys(clientPatch).length > 0) { const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); const current = existing ? (JSON.parse(existing) as Record) : {}; - localStorage.setItem( - CLIENT_SETTINGS_STORAGE_KEY, - JSON.stringify({ ...current, ...clientPatch }), - ); + const normalizedClientSettings = Schema.decodeSync(ClientSettingsSchema)({ + ...current, + ...clientPatch, + }); + localStorage.setItem(CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify(normalizedClientSettings)); } } catch (error) { console.error("[MIGRATION] Error migrating local settings:", error); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts new file mode 100644 index 00000000000..1075add983e --- /dev/null +++ b/packages/contracts/src/settings.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { + ClientSettingsSchema, + DEFAULT_TERMINAL_FONT_FAMILY, + normalizeTerminalFontFamily, +} from "./settings"; + +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); + +describe("ClientSettingsSchema", () => { + it("fills defaults for persisted settings that predate terminal font support", () => { + expect( + decodeClientSettings({ + confirmThreadDelete: false, + }), + ).toMatchObject({ + confirmThreadArchive: false, + confirmThreadDelete: false, + diffWordWrap: false, + terminalFontFamily: DEFAULT_TERMINAL_FONT_FAMILY, + }); + }); + + it("normalizes blank terminal font values back to the default stack", () => { + expect( + decodeClientSettings({ + terminalFontFamily: " ", + }), + ).toMatchObject({ + terminalFontFamily: DEFAULT_TERMINAL_FONT_FAMILY, + }); + }); + + it("trims custom terminal font stacks without altering internal separators", () => { + expect( + decodeClientSettings({ + terminalFontFamily: ' "MesloLGS NF", "JetBrainsMono Nerd Font", monospace ', + }), + ).toMatchObject({ + terminalFontFamily: '"MesloLGS NF", "JetBrainsMono Nerd Font", monospace', + }); + }); + + it("normalizes standalone terminal font values", () => { + expect(normalizeTerminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(normalizeTerminalFontFamily(' "MesloLGS NF", monospace ')).toBe( + '"MesloLGS NF", monospace', + ); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f994..c490baa550a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -22,6 +22,24 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; + +const TerminalFontFamilySetting = TrimmedString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value || DEFAULT_TERMINAL_FONT_FAMILY), + encode: (value) => Effect.succeed(value), + }), + ), +) + .check(Schema.isMaxLength(1024)) + .pipe(Schema.withDecodingDefault(() => DEFAULT_TERMINAL_FONT_FAMILY)); + +export function normalizeTerminalFontFamily(value: string): string { + return Schema.decodeSync(TerminalFontFamilySetting)(value); +} export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), @@ -34,6 +52,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), + terminalFontFamily: TerminalFontFamilySetting, }); export type ClientSettings = typeof ClientSettingsSchema.Type; From 3c572eb0b7a3177a68d7aadaaa72fd9513770575 Mon Sep 17 00:00:00 2001 From: Paul Opmann Date: Mon, 30 Mar 2026 13:06:53 +0300 Subject: [PATCH 2/4] cleaner normalizeClientPatch --- apps/web/src/hooks/useSettings.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a8858f4ae1f..fa86991bb67 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -66,16 +66,13 @@ function splitPatch(patch: Partial): { } function normalizeClientPatch(clientPatch: Partial): Partial { - if (!Object.hasOwn(clientPatch, "terminalFontFamily")) { + if (typeof clientPatch.terminalFontFamily !== "string") { return clientPatch; } return { ...clientPatch, - terminalFontFamily: - typeof clientPatch.terminalFontFamily === "string" - ? normalizeTerminalFontFamily(clientPatch.terminalFontFamily) - : clientPatch.terminalFontFamily, + terminalFontFamily: normalizeTerminalFontFamily(clientPatch.terminalFontFamily), }; } From 543385c1c61b340943c0acca1ad95493c8ae5e60 Mon Sep 17 00:00:00 2001 From: Paul Opmann Date: Mon, 30 Mar 2026 14:54:45 +0300 Subject: [PATCH 3/4] cleanup --- apps/web/src/hooks/useSettings.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fa86991bb67..7457630f887 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -65,17 +65,6 @@ function splitPatch(patch: Partial): { }; } -function normalizeClientPatch(clientPatch: Partial): Partial { - if (typeof clientPatch.terminalFontFamily !== "string") { - return clientPatch; - } - - return { - ...clientPatch, - terminalFontFamily: normalizeTerminalFontFamily(clientPatch.terminalFontFamily), - }; -} - // ── Hooks ──────────────────────────────────────────────────────────── /** @@ -136,8 +125,7 @@ export function useUpdateSettings() { } if (Object.keys(clientPatch).length > 0) { - const normalizedClientPatch = normalizeClientPatch(clientPatch); - setClientSettings((prev) => ({ ...prev, ...normalizedClientPatch })); + setClientSettings((prev) => ({ ...prev, ...clientPatch })); } }, [queryClient, setClientSettings], From 16c45d1449dd9ab5501f135e9418233f8e0f1323 Mon Sep 17 00:00:00 2001 From: Paul Opmann Date: Mon, 30 Mar 2026 14:59:21 +0300 Subject: [PATCH 4/4] useEffect prevent running on disposed Terminal --- apps/web/src/components/ThreadTerminalDrawer.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3ce8cf6e73d..eebb5bfe21f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -628,10 +628,11 @@ function TerminalViewport({ }, [cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - if (!terminal || !fitAddon) return; + if (!terminalRef.current || !fitAddonRef.current) return; const frame = window.requestAnimationFrame(() => { + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!terminal || !fitAddon) return; applyTerminalFontFamily(terminal, fitAddon, terminalFontFamily); }); return () => {