diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33..fb69beaaa2c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -32,6 +32,8 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, sidebarV2ConfiguredByUser: false, + terminalFontFamily: "JetBrainsMono Nerd Font", + terminalFontSize: 14, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678..dd08e20695c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,13 +1,97 @@ import { describe, expect, it } from "vite-plus/test"; import { + classifyTerminalExitTransition, resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, + shouldHandleLiveTerminalExit, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("classifyTerminalExitTransition", () => { + it("keeps an initially exited session visible after its buffer is replayed", () => { + expect( + classifyTerminalExitTransition({ + previousVersion: 0, + previousStatus: "closed", + currentStatus: "exited", + }), + ).toBe("initial"); + }); + + it("treats the synthetic closed baseline as an initial closed snapshot", () => { + expect( + classifyTerminalExitTransition({ + previousVersion: 0, + previousStatus: "closed", + currentStatus: "closed", + }), + ).toBe("initial"); + }); + + it("distinguishes a live exit from later exit snapshots", () => { + expect( + classifyTerminalExitTransition({ + previousVersion: 3, + previousStatus: "running", + currentStatus: "exited", + }), + ).toBe("live"); + expect( + classifyTerminalExitTransition({ + previousVersion: 4, + previousStatus: "closed", + currentStatus: "exited", + }), + ).toBe("none"); + expect( + classifyTerminalExitTransition({ + previousVersion: 5, + previousStatus: "exited", + currentStatus: "exited", + }), + ).toBe("none"); + }); +}); + +describe("shouldHandleLiveTerminalExit", () => { + it("tracks live exits independently of a surface remount baseline", () => { + expect( + shouldHandleLiveTerminalExit({ + previousStatus: "running", + currentStatus: "exited", + hasHandledExit: false, + }), + ).toBe(true); + }); + + it("ignores initial or already-handled exited snapshots", () => { + expect( + shouldHandleLiveTerminalExit({ + previousStatus: "closed", + currentStatus: "exited", + hasHandledExit: false, + }), + ).toBe(false); + expect( + shouldHandleLiveTerminalExit({ + previousStatus: "exited", + currentStatus: "exited", + hasHandledExit: false, + }), + ).toBe(false); + expect( + shouldHandleLiveTerminalExit({ + previousStatus: "running", + currentStatus: "exited", + hasHandledExit: true, + }), + ).toBe(false); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5c7f6a774ee..da38d32bcf8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -34,10 +34,13 @@ import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { + type GhosttyTerminalFont, GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions, } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; +import { useClientSettings } from "../hooks/useSettings"; +import { quoteTerminalFontFamily } from "../terminalAppearance"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; import { @@ -233,6 +236,47 @@ export function shouldHandleTerminalExit( ); } +/** + * Decide whether to print the "[terminal] ..." exit notice, and why. + * + * Separating this from `shouldHandleLiveTerminalExit` is the point: the notice + * and closing the drawer are different decisions. `"initial"` is the first + * snapshot of a session that had already ended before the drawer opened, so it + * deserves a notice explaining the empty-looking terminal but must not close it. + * `"live"` is a session that ended while being watched. Anything else already + * printed its notice, which is what stops a re-render from duplicating it. + */ +export function classifyTerminalExitTransition(options: { + previousVersion: number; + previousStatus: string; + currentStatus: string; +}): "none" | "initial" | "live" { + if (options.currentStatus !== "closed" && options.currentStatus !== "exited") { + return "none"; + } + if (options.previousVersion === 0) { + return "initial"; + } + return options.previousStatus === "running" ? "live" : "none"; +} + +/** + * Whether an exit should close the drawer. Requires an observed `running` → + * ended transition, so hydrating a terminal that had already exited leaves it + * on screen for the user to read instead of dismissing it out from under them. + */ +export function shouldHandleLiveTerminalExit(options: { + previousStatus: string; + currentStatus: string; + hasHandledExit: boolean; +}): boolean { + return ( + options.previousStatus === "running" && + (options.currentStatus === "closed" || options.currentStatus === "exited") && + !options.hasHandledExit + ); +} + interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; @@ -274,6 +318,23 @@ export function TerminalViewport({ }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); + const { terminalFontFamily, terminalFontSize } = useClientSettings(); + // `family` is spread in rather than set to `undefined`, because the surface + // treats an omitted field as "use the default" and `exactOptionalPropertyTypes` + // will not accept an explicit `undefined` in its place. + const requestedTerminalFont = useMemo( + () => ({ + ...(terminalFontFamily ? { family: quoteTerminalFontFamily(terminalFontFamily) } : {}), + size: terminalFontSize, + }), + [terminalFontFamily, terminalFontSize], + ); + // The surface is created asynchronously, so the creation effect reads the font + // through this ref rather than closing over it. Settings that hydrate while + // `create` is still in flight are picked up by the create path; settings that + // change after it resolves are picked up by the `setFont` effect below. + const requestedTerminalFontRef = useRef(requestedTerminalFont); + requestedTerminalFontRef.current = requestedTerminalFont; const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -291,6 +352,7 @@ export function TerminalViewport({ reportFailure: false, }); const hasHandledExitRef = useRef(false); + const previousTerminalStatusRef = useRef("closed"); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); @@ -330,24 +392,6 @@ export function TerminalViewport({ const terminalBuffer = terminalSession.buffer; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; - const synchronizedStatusRef = useRef("closed"); - const synchronizeTerminalStatus = useEffectEvent( - (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { - const synchronized = synchronizedStatusRef.current; - if (status === "running") { - hasHandledExitRef.current = false; - } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) { - hasHandledExitRef.current = true; - writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited"); - window.setTimeout(() => { - if (hasHandledExitRef.current) { - handleSessionExited(); - } - }, 0); - } - synchronizedStatusRef.current = status; - }, - ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ buffer: terminalBuffer, @@ -380,6 +424,7 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), + font: requestedTerminalFontRef.current, onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -401,12 +446,18 @@ export function TerminalViewport({ previousSessionRef.current = latestSession; if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); - // Attaching to a session that already exited must still run exit handling - // once, so mount synchronization starts from the empty "closed" state. - // (A session that is "closed" at mount is indistinguishable from one that - // never started, so only "exited" triggers the message — as with xterm.) - synchronizedStatusRef.current = "closed"; - synchronizeTerminalStatus(terminal, latestSession.status); + // Attaching to a session that already ended still explains itself, for + // "closed" as well as "exited" — otherwise hydrating a finished terminal + // shows a dead prompt with no indication why. It deliberately does not + // call `handleSessionExited`: only the observed running → ended transition + // in the status effect below closes the drawer, so a finished terminal + // stays on screen to be read. + if (latestSession.status === "closed" || latestSession.status === "exited") { + writeSystemMessage( + terminal, + latestSession.status === "closed" ? "Terminal closed" : "Process exited", + ); + } if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); const clearSelectionAction = () => { @@ -737,7 +788,6 @@ export function TerminalViewport({ } const previous = previousSessionRef.current; - synchronizeTerminalStatus(terminal, current.status); if (current.version === previous.version) { return; } @@ -756,6 +806,21 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } + // After the buffer, not before: the notice belongs at the end of the output + // it is describing. + if ( + classifyTerminalExitTransition({ + previousVersion: previous.version, + previousStatus: previous.status, + currentStatus: current.status, + }) !== "none" + ) { + writeSystemMessage( + terminal, + current.status === "closed" ? "Terminal closed" : "Process exited", + ); + } + if (previous.version === 0 && autoFocus) { window.requestAnimationFrame(() => { terminal.focus(); @@ -764,6 +829,42 @@ export function TerminalViewport({ previousSessionRef.current = current; }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + // Closing the drawer is driven by status alone, independently of buffer + // versions, so a session that ends without emitting more output still closes. + useEffect(() => { + const previousStatus = previousTerminalStatusRef.current; + previousTerminalStatusRef.current = terminalStatus; + if (terminalStatus === "running") { + hasHandledExitRef.current = false; + return; + } + if ( + !shouldHandleLiveTerminalExit({ + previousStatus, + currentStatus: terminalStatus, + hasHandledExit: hasHandledExitRef.current, + }) + ) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + }, [terminalStatus]); + + // `setFont` normalizes and clamps, and its epoch guard makes the newest call + // win regardless of font-load order, so this can fire freely as settings + // hydrate. Before the surface exists this is a no-op and the creation path + // reads the same values from the ref instead. + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + void terminal.setFont(requestedTerminalFont); + }, [requestedTerminalFont]); + 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 31ac4bba66e..312c68aa4c5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -15,6 +15,8 @@ import { defaultInstanceIdForDriver, type BackgroundActivityProfile, type BackgroundActivitySettings, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, type DesktopUpdateChannel, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, @@ -139,6 +141,10 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + normalizeTerminalFontFamilyInput, + normalizeTerminalFontSizeInput, +} from "../../terminalAppearance"; const THEME_OPTIONS = [ { @@ -586,6 +592,12 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Project Grouping"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.terminalFontFamily !== DEFAULT_UNIFIED_SETTINGS.terminalFontFamily + ? ["Terminal font family"] + : []), + ...(settings.terminalFontSize !== DEFAULT_UNIFIED_SETTINGS.terminalFontSize + ? ["Terminal font size"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -635,6 +647,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, + settings.terminalFontFamily, + settings.terminalFontSize, settings.wordWrap, theme, ], @@ -653,6 +667,8 @@ export function useSettingsRestore(onRestored?: () => void) { setTheme("system"); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily, + terminalFontSize: DEFAULT_UNIFIED_SETTINGS.terminalFontSize, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, @@ -1110,6 +1126,70 @@ export function AppearanceSettingsPanel() { } /> + + + + updateSettings({ + terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily, + }) + } + /> + ) : null + } + control={ + + updateSettings({ terminalFontFamily: normalizeTerminalFontFamilyInput(next) }) + } + placeholder="Auto (Nerd Font compatible)" + spellCheck={false} + aria-label="Terminal font family" + /> + } + /> + + + updateSettings({ + terminalFontSize: DEFAULT_UNIFIED_SETTINGS.terminalFontSize, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + terminalFontSize: normalizeTerminalFontSizeInput(next), + }) + } + aria-label="Terminal font size" + /> + } + /> + ); } diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 464f92547e5..ab523c52fe9 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -84,4 +84,26 @@ describe("searchSettings", () => { targetId: "appearance", }); }); + + it("routes terminal font settings to the appearance section", () => { + expect(searchSettings("font family")[0]).toMatchObject({ + id: "terminal-font-family", + to: "/settings/appearance", + }); + expect(searchSettings("font size")[0]).toMatchObject({ + id: "terminal-font-size", + to: "/settings/appearance", + }); + expect(searchSettings("font").map((item) => item.id)).toEqual([ + "terminal-font-family", + "terminal-font-size", + ]); + }); + + it("finds terminal font settings by their section name", () => { + expect(searchSettings("terminal").map((item) => item.id)).toEqual([ + "terminal-font-family", + "terminal-font-size", + ]); + }); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4ead6eff4d7..22424b5dd5b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -60,6 +60,19 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Word wrap", to: "/settings/appearance", }, + { + id: "terminal-font-family", + // Titled with "Terminal" so the section header's context reaches the + // index too: search matches titles only, and "terminal" is the term a + // user reaches for before "font". + title: "Terminal font family", + to: "/settings/appearance", + }, + { + id: "terminal-font-size", + title: "Terminal font size", + to: "/settings/appearance", + }, { id: "project-grouping", title: "Project grouping", diff --git a/apps/web/src/terminalAppearance.test.ts b/apps/web/src/terminalAppearance.test.ts new file mode 100644 index 00000000000..2a89fbbe154 --- /dev/null +++ b/apps/web/src/terminalAppearance.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_TERMINAL_FONT_SIZE, + normalizeTerminalFontFamilyInput, + normalizeTerminalFontSize, + normalizeTerminalFontSizeInput, + quoteTerminalFontFamily, +} from "./terminalAppearance"; + +describe("quoteTerminalFontFamily", () => { + it("escapes the characters that would otherwise end the CSS string", () => { + expect(quoteTerminalFontFamily("MesloLGS NF")).toBe('"MesloLGS NF"'); + expect(quoteTerminalFontFamily('My "Nerd" \\ Font')).toBe('"My \\"Nerd\\" \\\\ Font"'); + }); + + it("keeps a family containing a comma from splitting the font list", () => { + // Unquoted, this would read as two families and silently change the stack. + expect(quoteTerminalFontFamily("Comma, Face")).toBe('"Comma, Face"'); + }); + + it("flattens newlines that would terminate the declaration", () => { + expect(quoteTerminalFontFamily("Line\nBreak")).toBe('"Line Break"'); + }); +}); + +describe("normalizeTerminalFontSize", () => { + it("rounds, clamps, and falls back for invalid input", () => { + expect(normalizeTerminalFontSize(14)).toBe(14); + expect(normalizeTerminalFontSize(14.6)).toBe(15); + expect(normalizeTerminalFontSize(2)).toBe(8); + expect(normalizeTerminalFontSize(80)).toBe(32); + expect(normalizeTerminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + }); + + it("normalizes values committed by the settings inputs", () => { + expect(normalizeTerminalFontFamilyInput(" MesloLGS NF ")).toBe("MesloLGS NF"); + expect(normalizeTerminalFontFamilyInput(" ")).toBe(""); + expect(normalizeTerminalFontSizeInput("")).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(normalizeTerminalFontSizeInput(" ")).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(normalizeTerminalFontSizeInput("14.6")).toBe(15); + expect(normalizeTerminalFontSizeInput("2")).toBe(8); + expect(normalizeTerminalFontSizeInput("80")).toBe(32); + expect(normalizeTerminalFontSizeInput("not-a-number")).toBe(DEFAULT_TERMINAL_FONT_SIZE); + }); +}); diff --git a/apps/web/src/terminalAppearance.ts b/apps/web/src/terminalAppearance.ts new file mode 100644 index 00000000000..636a51dd877 --- /dev/null +++ b/apps/web/src/terminalAppearance.ts @@ -0,0 +1,37 @@ +import { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, +} from "@t3tools/contracts"; + +export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE }; + +/** + * Wrap a family name in a CSS string so a face containing a comma, a leading + * digit, or a reserved keyword cannot change the meaning of the font list it is + * spliced into. The Ghostty surface appends its glyph fallbacks after whatever + * it is handed and does not quote, so quoting has to happen here. + */ +export function quoteTerminalFontFamily(fontFamily: string): string { + const escaped = fontFamily + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replace(/[\r\n\f]/g, " "); + return `"${escaped}"`; +} + +export function normalizeTerminalFontSize(fontSize: number): number { + if (!Number.isFinite(fontSize)) return DEFAULT_TERMINAL_FONT_SIZE; + return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, Math.round(fontSize))); +} + +export function normalizeTerminalFontFamilyInput(fontFamily: string): string { + return fontFamily.trim(); +} + +export function normalizeTerminalFontSizeInput(fontSize: string): number { + const normalized = fontSize.trim(); + return normalizeTerminalFontSize( + normalized.length === 0 ? DEFAULT_TERMINAL_FONT_SIZE : Number(normalized), + ); +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5bd22e95f20..93154eb326d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -6,6 +6,7 @@ import { ClientSettingsSchema, ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, + DEFAULT_TERMINAL_FONT_SIZE, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; @@ -111,6 +112,38 @@ describe("ClientSettings sidebar v2", () => { }); }); +describe("ClientSettings terminal appearance", () => { + it("hydrates legacy settings with terminal defaults", () => { + const decoded = decodeClientSettings({ wordWrap: false }); + expect(decoded.terminalFontFamily).toBe(""); + expect(decoded.terminalFontSize).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(decoded.wordWrap).toBe(false); + }); + + it("trims and round-trips valid terminal appearance preferences", () => { + const decoded = decodeClientSettings({ + terminalFontFamily: " MesloLGS NF ", + terminalFontSize: 14, + }); + expect(decoded.terminalFontFamily).toBe("MesloLGS NF"); + expect(decoded.terminalFontSize).toBe(14); + }); + + it("accepts terminal appearance patches and rejects invalid sizes", () => { + expect( + decodeClientSettingsPatch({ + terminalFontFamily: " JetBrainsMono Nerd Font ", + terminalFontSize: 15, + }), + ).toEqual({ + terminalFontFamily: "JetBrainsMono Nerd Font", + terminalFontSize: 15, + }); + expect(() => decodeClientSettingsPatch({ terminalFontSize: 7 })).toThrow(); + expect(() => decodeClientSettingsPatch({ terminalFontSize: 12.5 })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2a0e087d06c..6b8b2aca478 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -66,6 +66,23 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +// The web renderer clamps to its own 6..32 range in +// `apps/web/src/terminal/ghostty/surface.ts`. These bounds are deliberately +// narrower, so every value the contract accepts survives the renderer +// untouched — no setting silently disagrees with what gets drawn. Contracts +// cannot import from apps/web, so the two definitions stay separate; keep this +// range inside the renderer's if either side moves. +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 32; +export const DEFAULT_TERMINAL_FONT_SIZE = 12; +export const TerminalFontSize = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_TERMINAL_FONT_SIZE, + maximum: MAX_TERMINAL_FONT_SIZE, + }), +); +export type TerminalFontSize = typeof TerminalFontSize.Type; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -131,6 +148,12 @@ export const ClientSettingsSchema = Schema.Struct({ // there is no way to tell that apart from "left alone", and a channel-derived // default could never reach them. Mirrors `updateChannelConfiguredByUser`. sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // An empty family means "let the renderer pick"; the web surface resolves it + // to its own default stack and appends the Nerd Font glyph fallbacks. + terminalFontFamily: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + terminalFontSize: TerminalFontSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -721,6 +744,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), + terminalFontFamily: Schema.optionalKey(TrimmedString), + terminalFontSize: Schema.optionalKey(TerminalFontSize), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), });