Skip to content

Commit 1f3485b

Browse files
ASRagabclaude
andcommitted
feat: dynamic system font detection for terminal font picker
Replace the hardcoded 10-font list with dynamic system font enumeration via fc-list IPC, falling back to canvas-based detection when unavailable. - Add GetSystemFonts IPC channel that runs `fc-list :spacing=mono` to enumerate all installed monospace fonts (works on Linux natively, macOS via fontconfig) - Fix canvas-based font detection false negative on macOS where the default monospace font (Menlo) matches the test baseline - Widen TerminalFont type from union to string throughout store and persistence layer to support arbitrary font families - Filter weight variants by taking only primary family name from fc-list output (283 → ~93 entries) - Make SettingsDialog font list async-aware via SolidJS signal + effect - Fix pre-existing bug: check_is_git_repo missing from preload allowlist - Add .letta/ to .prettierignore Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4792390 commit 1f3485b

10 files changed

Lines changed: 140 additions & 42 deletions

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ release/
55
node_modules/
66
.worktrees/
77
.claude/
8+
.letta/
89
package-lock.json
910
*.AppImage
1011
*.deb

electron/ipc/channels.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ export enum IPC {
9393
CheckDockerImageExists = 'check_docker_image_exists',
9494
BuildDockerImage = 'build_docker_image',
9595

96+
// System
97+
GetSystemFonts = 'get_system_fonts',
98+
9699
// Notifications
97100
ShowNotification = 'show_notification',
98101
NotificationClicked = 'notification_clicked',

electron/ipc/register.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { listAgents } from './agents.js';
5050
import { saveAppState, loadAppState } from './persistence.js';
5151
import { spawn } from 'child_process';
5252
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
53+
import { getSystemMonospaceFonts } from './system-fonts.js';
5354
import path from 'path';
5455
import {
5556
assertString,
@@ -407,6 +408,9 @@ export function registerAllHandlers(win: BrowserWindow): void {
407408
cancelAskAboutCode(args.requestId);
408409
});
409410

411+
// --- System ---
412+
ipcMain.handle(IPC.GetSystemFonts, () => getSystemMonospaceFonts());
413+
410414
// --- Notifications (fire-and-forget via ipcMain.on) ---
411415
const activeNotifications = new Set<Notification>();
412416
ipcMain.on(IPC.ShowNotification, (_e, args) => {

electron/ipc/system-fonts.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { execFile } from 'child_process';
2+
3+
let cachedFonts: string[] | null = null;
4+
5+
export async function getSystemMonospaceFonts(): Promise<string[]> {
6+
if (cachedFonts) return cachedFonts;
7+
8+
try {
9+
const fonts = await queryFcList();
10+
cachedFonts = fonts;
11+
return fonts;
12+
} catch {
13+
cachedFonts = [];
14+
return [];
15+
}
16+
}
17+
18+
function queryFcList(): Promise<string[]> {
19+
return new Promise((resolve, reject) => {
20+
execFile('fc-list', [':spacing=mono', 'family'], { timeout: 5000 }, (err, stdout) => {
21+
if (err) return reject(err);
22+
const families = new Set<string>();
23+
for (const line of stdout.split('\n')) {
24+
const trimmed = line.trim();
25+
if (!trimmed) continue;
26+
// fc-list outputs comma-separated names: primary family first, then aliases
27+
// for weight variants (e.g. "BlexMono Nerd Font,BlexMono Nerd Font Light").
28+
// Taking only the first name collapses all weight variants into one entry.
29+
const primary = trimmed.split(',')[0].trim();
30+
if (primary && !primary.startsWith('.')) families.add(primary);
31+
}
32+
resolve([...families].sort((a, b) => a.localeCompare(b)));
33+
});
34+
});
35+
}

electron/preload.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const ALLOWED_CHANNELS = new Set([
3535
'rebase_task',
3636
'get_main_branch',
3737
'get_current_branch',
38+
'check_is_git_repo',
3839
// Persistence
3940
'save_app_state',
4041
'load_app_state',
@@ -85,6 +86,8 @@ const ALLOWED_CHANNELS = new Set([
8586
// Ask about code
8687
'ask_about_code',
8788
'cancel_ask_about_code',
89+
// System
90+
'get_system_fonts',
8891
// Notifications
8992
'show_notification',
9093
'notification_clicked',

src/components/SettingsDialog.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { For, Show, createMemo } from 'solid-js';
1+
import { For, Show, createSignal, createEffect, on } from 'solid-js';
22
import { Dialog } from './Dialog';
3-
import { getAvailableTerminalFonts, getTerminalFontFamily, LIGATURE_FONTS } from '../lib/fonts';
3+
import {
4+
getAvailableTerminalFonts,
5+
fetchAvailableTerminalFonts,
6+
getTerminalFontFamily,
7+
LIGATURE_FONTS,
8+
} from '../lib/fonts';
49
import { LOOK_PRESETS } from '../lib/look';
510
import { theme, sectionLabelStyle } from '../lib/theme';
611
import {
@@ -16,20 +21,33 @@ import {
1621
} from '../store/store';
1722
import { CustomAgentEditor } from './CustomAgentEditor';
1823
import { mod } from '../lib/platform';
19-
import type { TerminalFont } from '../lib/fonts';
2024

2125
interface SettingsDialogProps {
2226
open: boolean;
2327
onClose: () => void;
2428
}
2529

30+
function ensureSelectedFont(available: string[]): string[] {
31+
if (available.includes(store.terminalFont)) return available;
32+
return [store.terminalFont, ...available];
33+
}
34+
2635
export function SettingsDialog(props: SettingsDialogProps) {
27-
const fonts = createMemo<TerminalFont[]>(() => {
28-
const available = getAvailableTerminalFonts();
29-
// Always include the currently selected font so it stays visible even if detection misses it
30-
if (available.includes(store.terminalFont)) return available;
31-
return [store.terminalFont, ...available];
32-
});
36+
const [fonts, setFonts] = createSignal<string[]>(ensureSelectedFont(getAvailableTerminalFonts()));
37+
38+
// Fetch system fonts when the dialog opens
39+
createEffect(
40+
on(
41+
() => props.open,
42+
(open) => {
43+
if (open) {
44+
fetchAvailableTerminalFonts().then((available) =>
45+
setFonts(ensureSelectedFont(available)),
46+
);
47+
}
48+
},
49+
),
50+
);
3351

3452
return (
3553
<Dialog

src/lib/fonts.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { IPC } from '../../electron/ipc/channels';
2+
3+
/** Well-known monospace fonts used as fallback when system font enumeration is unavailable. */
14
export const TERMINAL_FONTS = [
25
'JetBrains Mono',
36
'Fira Code',
@@ -11,53 +14,83 @@ export const TERMINAL_FONTS = [
1114
'Consolas',
1215
] as const;
1316

14-
export type TerminalFont = (typeof TERMINAL_FONTS)[number];
15-
16-
export const DEFAULT_TERMINAL_FONT: TerminalFont = 'JetBrains Mono';
17-
18-
export function isTerminalFont(v: unknown): v is TerminalFont {
19-
return typeof v === 'string' && (TERMINAL_FONTS as readonly string[]).includes(v);
20-
}
17+
export const DEFAULT_TERMINAL_FONT: string = 'JetBrains Mono';
2118

2219
/** Fonts that ship with programming ligatures (disabled in terminal via CSS). */
23-
export const LIGATURE_FONTS: ReadonlySet<TerminalFont> = new Set([
20+
export const LIGATURE_FONTS: ReadonlySet<string> = new Set([
2421
'JetBrains Mono',
2522
'Fira Code',
2623
'Cascadia Code',
2724
]);
2825

29-
export function getTerminalFontFamily(font: TerminalFont): string {
26+
export function getTerminalFontFamily(font: string): string {
3027
return `'${font}', monospace`;
3128
}
3229

3330
/** Fonts loaded via Google Fonts — always available regardless of local install. */
34-
const WEB_FONTS: ReadonlySet<TerminalFont> = new Set(['JetBrains Mono']);
31+
const WEB_FONTS: ReadonlySet<string> = new Set(['JetBrains Mono']);
32+
33+
/**
34+
* Returns monospace fonts available on this system.
35+
* Uses IPC to query the main process (fc-list), falling back to canvas-based
36+
* detection of the hardcoded TERMINAL_FONTS list.
37+
*/
38+
let systemFontsPromise: Promise<string[]> | null = null;
39+
let systemFontsResult: string[] | null = null;
40+
41+
export function getAvailableTerminalFonts(): string[] {
42+
// Return cached result synchronously if available
43+
if (systemFontsResult) return systemFontsResult;
44+
// Return fallback (web fonts only) while async fetch is in progress
45+
return [...WEB_FONTS];
46+
}
3547

36-
/** Returns the subset of TERMINAL_FONTS that are installed on this system. Cached after first call. */
37-
let availableCache: TerminalFont[] | null = null;
48+
export async function fetchAvailableTerminalFonts(): Promise<string[]> {
49+
if (systemFontsResult) return systemFontsResult;
50+
if (!systemFontsPromise) {
51+
systemFontsPromise = loadSystemFonts();
52+
}
53+
return systemFontsPromise;
54+
}
3855

39-
export function getAvailableTerminalFonts(): TerminalFont[] {
40-
if (availableCache) return availableCache;
56+
async function loadSystemFonts(): Promise<string[]> {
57+
try {
58+
const systemFonts = (await window.electron.ipcRenderer.invoke(IPC.GetSystemFonts)) as string[];
59+
if (systemFonts.length === 0) {
60+
// fc-list unavailable or returned nothing — use canvas fallback
61+
systemFontsResult = detectFontsViaCanvas();
62+
} else {
63+
// Merge web fonts (always available) with system fonts, deduplicated
64+
const all = new Set<string>([...WEB_FONTS, ...systemFonts]);
65+
systemFontsResult = [...all].sort((a, b) => a.localeCompare(b));
66+
}
67+
} catch {
68+
// IPC failed — fall back to canvas-based detection of hardcoded list
69+
systemFontsResult = detectFontsViaCanvas();
70+
}
71+
return systemFontsResult;
72+
}
4173

74+
/** Canvas-based detection of the hardcoded TERMINAL_FONTS list (fallback). */
75+
function detectFontsViaCanvas(): string[] {
4276
const canvas = document.createElement('canvas');
4377
const ctx = canvas.getContext('2d');
44-
if (!ctx) {
45-
availableCache = [...TERMINAL_FONTS];
46-
return availableCache;
47-
}
78+
if (!ctx) return [...TERMINAL_FONTS];
4879

4980
const testString = 'mmmmmmmmmmlli';
5081
const fontSize = '72px';
51-
const fallback = 'monospace';
82+
const fallbacks = ['serif', 'sans-serif'] as const;
5283

53-
ctx.font = `${fontSize} ${fallback}`;
54-
const baseWidth = ctx.measureText(testString).width;
84+
const baseWidths = fallbacks.map((fb) => {
85+
ctx.font = `${fontSize} ${fb}`;
86+
return ctx.measureText(testString).width;
87+
});
5588

56-
availableCache = TERMINAL_FONTS.filter((font) => {
89+
return TERMINAL_FONTS.filter((font) => {
5790
if (WEB_FONTS.has(font)) return true;
58-
ctx.font = `${fontSize} '${font}', ${fallback}`;
59-
return ctx.measureText(testString).width !== baseWidth;
91+
return fallbacks.some((fb, i) => {
92+
ctx.font = `${fontSize} '${font}', ${fb}`;
93+
return ctx.measureText(testString).width !== baseWidths[i];
94+
});
6095
});
61-
62-
return availableCache;
6396
}

src/store/persistence.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
Project,
1515
} from './types';
1616
import type { AgentDef } from '../ipc/types';
17-
import { DEFAULT_TERMINAL_FONT, isTerminalFont } from '../lib/fonts';
17+
import { DEFAULT_TERMINAL_FONT } from '../lib/fonts';
1818
import { isLookPreset } from '../lib/look';
1919
import { syncTerminalCounter } from './terminals';
2020

@@ -283,7 +283,10 @@ export async function loadState(): Promise<void> {
283283
typeof mergedLinesRemovedRaw === 'number' && Number.isFinite(mergedLinesRemovedRaw)
284284
? Math.max(0, Math.floor(mergedLinesRemovedRaw))
285285
: 0;
286-
s.terminalFont = isTerminalFont(raw.terminalFont) ? raw.terminalFont : DEFAULT_TERMINAL_FONT;
286+
s.terminalFont =
287+
typeof raw.terminalFont === 'string' && raw.terminalFont.trim()
288+
? raw.terminalFont
289+
: DEFAULT_TERMINAL_FONT;
287290
s.themePreset = isLookPreset(raw.themePreset) ? raw.themePreset : 'minimal';
288291
s.windowState = parsePersistedWindowState(raw.windowState);
289292
s.autoTrustFolders = typeof raw.autoTrustFolders === 'boolean' ? raw.autoTrustFolders : false;

src/store/types.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { AgentDef, WorktreeStatus } from '../ipc/types';
2-
import type { TerminalFont } from '../lib/fonts';
32
import type { LookPreset } from '../lib/look';
43

54
export interface TerminalBookmark {
@@ -113,7 +112,7 @@ export interface PersistedState {
113112
completedTaskCount?: number;
114113
mergedLinesAdded?: number;
115114
mergedLinesRemoved?: number;
116-
terminalFont?: TerminalFont;
115+
terminalFont?: string;
117116
themePreset?: LookPreset;
118117
windowState?: PersistedWindowState;
119118
autoTrustFolders?: boolean;
@@ -177,7 +176,7 @@ export interface AppStore {
177176
completedTaskCount: number;
178177
mergedLinesAdded: number;
179178
mergedLinesRemoved: number;
180-
terminalFont: TerminalFont;
179+
terminalFont: string;
181180
themePreset: LookPreset;
182181
windowState: PersistedWindowState | null;
183182
autoTrustFolders: boolean;

src/store/ui.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { produce } from 'solid-js/store';
22
import { store, setStore } from './core';
3-
import type { TerminalFont } from '../lib/fonts';
43
import type { LookPreset } from '../lib/look';
54
import type { PersistedWindowState } from './types';
65

@@ -71,7 +70,7 @@ export function toggleSidebar(): void {
7170
setStore('sidebarVisible', !store.sidebarVisible);
7271
}
7372

74-
export function setTerminalFont(terminalFont: TerminalFont): void {
73+
export function setTerminalFont(terminalFont: string): void {
7574
setStore('terminalFont', terminalFont);
7675
}
7776

0 commit comments

Comments
 (0)