Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import {
applyTerminalFontFamily,
resolveTerminalSelectionActionPosition,
shouldHandleTerminalSelectionMouseUp,
terminalSelectionActionDelayForClickCount,
Expand Down Expand Up @@ -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);
});
});
40 changes: 39 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -207,6 +230,8 @@ function TerminalViewport({
resizeEpoch,
drawerHeight,
}: TerminalViewportProps) {
const settings = useSettings();
const terminalFontFamily = settings.terminalFontFamily;
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -602,6 +627,19 @@ function TerminalViewport({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, runtimeEnv, terminalId, threadId]);

useEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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 () => {
window.cancelAnimationFrame(frame);
};
}, [terminalFontFamily]);

useEffect(() => {
if (!autoFocus) return;
const terminal = terminalRef.current;
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
: []),
Expand All @@ -486,6 +489,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.terminalFontFamily,
settings.timestampFormat,
theme,
],
Expand Down Expand Up @@ -838,6 +842,36 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Terminal font"
description="Use any installed monospaced font or Nerd Font stack for terminal glyph coverage."
resetAction={
settings.terminalFontFamily !== DEFAULT_UNIFIED_SETTINGS.terminalFontFamily ? (
<SettingResetButton
label="terminal font"
onClick={() =>
updateSettings({
terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily,
})
}
/>
) : null
}
control={
<Input
className="w-full sm:w-[26rem]"
aria-label="Terminal font family"
placeholder='"MesloLGS NF", "JetBrainsMono Nerd Font", "SF Mono", Consolas, monospace'
value={settings.terminalFontFamily}
onChange={(event) =>
updateSettings({
terminalFontFamily: event.target.value,
})
}
/>
}
/>

<SettingsRow
title="Assistant output"
description="Show token-by-token output while a response is in progress."
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_TERMINAL_FONT_FAMILY } from "@t3tools/contracts/settings";
import { buildLegacyClientSettingsMigrationPatch } from "./useSettings";

describe("buildLegacyClientSettingsMigrationPatch", () => {
Expand All @@ -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,
});
});
});
17 changes: 13 additions & 4 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -226,6 +228,12 @@ export function buildLegacyClientSettingsMigrationPatch(
patch.timestampFormat = legacySettings.timestampFormat;
}

if (Predicate.isString(legacySettings.terminalFontFamily)) {
patch.terminalFontFamily = normalizeTerminalFontFamily(
legacySettings.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY,
);
}

return patch;
}

Expand Down Expand Up @@ -256,10 +264,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<string, unknown>) : {};
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);
Expand Down
52 changes: 52 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
19 changes: 19 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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;

Expand Down
Loading