Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
});
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/appTypography.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions apps/web/src/appTypography.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +25 to +26
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_MONO_FONT_FAMILY duplicates the default mono stack defined in index.css (--app-mono-font-family). This creates a drift risk where the terminal fallback and the CSS default could diverge over time. Consider reusing a single source of truth (e.g., export the default stack from one place, or fall back to a generic monospace when the CSS variable is missing).

Suggested change
const DEFAULT_MONO_FONT_FAMILY =
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace';
const DEFAULT_MONO_FONT_FAMILY = "monospace";

Copilot uses AI. Check for mistakes.

const UI_FONT_SIZE_PX: Record<UiFontSize, number> = {
sm: 15,
md: 16,
lg: 17,
};

const TERMINAL_FONT_SIZE_PX: Record<TerminalFontSize, number> = {
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,
};
}
122 changes: 121 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
});
});
});
Loading
Loading