Skip to content
Merged
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
149 changes: 149 additions & 0 deletions apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
sanitizeTerminalFontFamily,
} from "./index";

type MeasureFn = (text: string) => { width: number };

/**
* Stub `document.createElement("canvas")` so `getContext("2d").measureText`
* returns widths from `measureForFont`. Non-canvas tags defer to the
* existing test-setup stub.
*/
function stubCanvas(measureForFont: (font: string) => MeasureFn) {
const originalCreate = document.createElement;
// biome-ignore lint/suspicious/noExplicitAny: bun:test `mock` wraps arbitrary fns
(document as any).createElement = mock((tag: string) => {
if (tag !== "canvas") {
// biome-ignore lint/suspicious/noExplicitAny: delegating stub accepts any tag
return (originalCreate as any).call(document, tag);
}
let currentFont = "";
return {
getContext: (kind: string) => {
if (kind !== "2d") return null;
return {
set font(value: string) {
currentFont = value;
},
get font() {
return currentFont;
},
measureText: (text: string) => measureForFont(currentFont)(text),
};
},
};
});
return () => {
// biome-ignore lint/suspicious/noExplicitAny: restoring stubbed method
(document as any).createElement = originalCreate;
};
}

const equalWidths: MeasureFn = (text) => ({ width: text.length * 10 });
const proportionalWidths: MeasureFn = (text) => {
let width = 0;
for (const ch of text) width += ch === "M" ? 16 : 6;
return { width };
};

describe("sanitizeTerminalFontFamily", () => {
let restore: (() => void) | null = null;

afterEach(() => {
restore?.();
restore = null;
});

test("returns default for null / empty / whitespace", () => {
expect(sanitizeTerminalFontFamily(null)).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
expect(sanitizeTerminalFontFamily(undefined)).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
expect(sanitizeTerminalFontFamily("")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
expect(sanitizeTerminalFontFamily(" ")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("trusts all-generic monospace values without canvas", () => {
expect(sanitizeTerminalFontFamily("monospace")).toBe("monospace");
expect(sanitizeTerminalFontFamily("ui-monospace")).toBe("ui-monospace");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("falls back when the primary family is a proportional generic", () => {
expect(sanitizeTerminalFontFamily("sans-serif")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
expect(sanitizeTerminalFontFamily("serif")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
expect(sanitizeTerminalFontFamily("cursive")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
// CSS resolves the first generic, so a later monospace entry never wins.
expect(sanitizeTerminalFontFamily("cursive, monospace")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("passes through a stack whose primary generic is monospace", () => {
// The browser resolves the first generic, so "monospace, sans-serif"
// actually renders as monospace — safe.
expect(sanitizeTerminalFontFamily("monospace, sans-serif")).toBe(
"monospace, sans-serif",
);
});

test("falls back when a concrete mono follows a proportional generic", () => {
// Regression: earlier logic picked the first non-generic as the primary,
// letting `sans-serif, "JetBrains Mono"` slip through even though CSS
// renders sans-serif. Validate the actual CSS primary instead.
expect(sanitizeTerminalFontFamily('sans-serif, "JetBrains Mono"')).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("passes a monospace font through when the stack already ends with monospace", () => {
restore = stubCanvas(() => equalWidths);
expect(sanitizeTerminalFontFamily('"JetBrains Mono", monospace')).toBe(
'"JetBrains Mono", monospace',
);
});

test("appends a monospace fallback when the stack lacks one", () => {
// If the primary isn't installed, the browser otherwise falls back to a
// proportional default — appending "monospace" forces OS monospace.
restore = stubCanvas(() => equalWidths);
expect(sanitizeTerminalFontFamily('"JetBrains Mono"')).toBe(
'"JetBrains Mono", monospace',
);
expect(sanitizeTerminalFontFamily("Menlo")).toBe("Menlo, monospace");
});

test("falls back to default for a proportional primary family (quoted)", () => {
restore = stubCanvas(() => proportionalWidths);
expect(sanitizeTerminalFontFamily('"Inter", sans-serif')).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("falls back to default for a proportional primary family (bare)", () => {
restore = stubCanvas(() => proportionalWidths);
expect(sanitizeTerminalFontFamily("Inter")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("trusts the value when canvas measurement throws", () => {
restore = stubCanvas(() => () => {
throw new Error("canvas unsupported");
});
// Use a unique family so the module-level monospace cache doesn't mask
// the canvas error path.
expect(sanitizeTerminalFontFamily('"UnmeasurableFont-ABC-123"')).toBe(
'"UnmeasurableFont-ABC-123", monospace',
);
});
});
108 changes: 108 additions & 0 deletions apps/desktop/src/renderer/lib/terminal/appearance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,114 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = serializeFontFamilyList([

export const DEFAULT_TERMINAL_FONT_SIZE = 14;

const MONOSPACE_GENERIC_FAMILIES = new Set(["monospace", "ui-monospace"]);

/** Parse a CSS font-family list into trimmed entries, respecting quoted names. */
function parseFontFamilyList(cssValue: string): string[] {
const families: string[] = [];
let current = "";
let inQuote: string | null = null;

for (const ch of cssValue) {
if (inQuote) {
if (ch === inQuote) inQuote = null;
else current += ch;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ",") {
const trimmed = current.trim();
if (trimmed) families.push(trimmed);
current = "";
} else {
current += ch;
}
}
const last = current.trim();
if (last) families.push(last);
return families;
}

const monospaceCheckCache = new Map<string, boolean>();

/**
* Heuristically decide whether `family` is a monospace font using canvas
* measurement — monospace fonts render narrow ("iiiiii") and wide ("MMMMMM")
* runs at the same width. Returns `true` (permissive) when the canvas API
* is unavailable (tests/SSR) so we never block a legitimate font.
*/
function isFontFamilyMonospace(family: string): boolean {
const key = family.toLowerCase();
if (MONOSPACE_GENERIC_FAMILIES.has(key)) return true;

const cached = monospaceCheckCache.get(key);
if (cached !== undefined) return cached;

try {
if (typeof document === "undefined") return true;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext?.("2d");
if (!ctx) return true;

ctx.font = `16px "${family}"`;
const narrow = ctx.measureText("iiiiii").width;
const wide = ctx.measureText("MMMMMM").width;
// Sub-pixel jitter tolerance.
const isMono = Math.abs(narrow - wide) < 1;
monospaceCheckCache.set(key, isMono);
return isMono;
} catch {
return true;
}
}

/**
* Guard against a persisted terminal font that would break xterm rendering
* (e.g. a proportional font like "Inter"). Returns the raw CSS value when
* the primary family is monospace; otherwise falls back to the bundled
* default so a poisoned setting can never blank the app on startup.
*
* See issue #3513. The settings UI already prevents new non-monospace
* selections for the terminal, but this recovers users whose DB was
* poisoned before the UI restriction was added.
*/
export function sanitizeTerminalFontFamily(
cssValue: string | null | undefined,
): string {
if (!cssValue || !cssValue.trim()) return DEFAULT_TERMINAL_FONT_FAMILY;
const families = parseFontFamilyList(cssValue);
if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY;

// Validate the actual CSS primary (first entry), not the first non-generic.
// A value like `sans-serif, "JetBrains Mono"` resolves to sans-serif in the
// browser regardless of what follows, so inspecting the later entry would
// let proportional stacks slip through.
const primary = families[0];
const primaryKey = primary.toLowerCase();

if (GENERIC_FONT_FAMILIES.has(primaryKey)) {
if (MONOSPACE_GENERIC_FAMILIES.has(primaryKey)) return cssValue;
console.warn(
`[terminal] Font stack "${cssValue}" has no monospace primary family; falling back to default terminal font.`,
);
return DEFAULT_TERMINAL_FONT_FAMILY;
}

if (!isFontFamilyMonospace(primary)) {
console.warn(
`[terminal] Font "${primary}" is not monospace; falling back to default terminal font.`,
);
return DEFAULT_TERMINAL_FONT_FAMILY;
}
// Ensure a generic monospace tail — if the configured primary isn't
// installed on this machine, the browser falls back to the OS monospace
// generic instead of a proportional default (mirrors VS Code's behavior
// in src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts).
const hasMonoTail = families.some((f) =>
MONOSPACE_GENERIC_FAMILIES.has(f.toLowerCase()),
);
return hasMonoTail ? cssValue : `${cssValue}, monospace`;
}

/** Reads localStorage theme cache for flash-free first paint. */
export function getDefaultTerminalAppearance(): TerminalAppearance {
const theme = readCachedTerminalTheme();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
getDefaultTerminalAppearance,
sanitizeTerminalFontFamily,
type TerminalAppearance,
} from "renderer/lib/terminal/appearance";
import { electronTrpcClient } from "renderer/lib/trpc-client";
Expand All @@ -21,8 +21,9 @@ export function useTerminalAppearance(): TerminalAppearance {

return useMemo(() => {
const theme = terminalTheme ?? fallbackTheme;
const fontFamily =
fontSettings?.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY;
const fontFamily = sanitizeTerminalFontFamily(
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Choose a reason for hiding this comment

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

P2: Sanitization still lets all-generic non-monospace font stacks through, so the terminal can still be launched with a proportional font.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts, line 24:

<comment>Sanitization still lets all-generic non-monospace font stacks through, so the terminal can still be launched with a proportional font.</comment>

<file context>
@@ -21,8 +21,9 @@ export function useTerminalAppearance(): TerminalAppearance {
 		const theme = terminalTheme ?? fallbackTheme;
-		const fontFamily =
-			fontSettings?.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY;
+		const fontFamily = sanitizeTerminalFontFamily(
+			fontSettings?.terminalFontFamily,
+		);
</file context>
Fix with Cubic

fontSettings?.terminalFontFamily,
);
const fontSize =
fontSettings?.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function FontFamilyCombobox({
return { nerdFonts: nerd, monoFonts: mono, otherFonts: other };
}, [fonts]);

// Terminal fonts must be monospace — arbitrary free-form names would let
// users pick proportional fonts (see issue #3513), so the custom-entry
// escape hatches below are gated off for the terminal variant.
const allowCustomEntry = variant !== "terminal";

const hasExactMatch = useMemo(() => {
if (!search.trim()) return true;
const lower = search.toLowerCase().trim();
Expand Down Expand Up @@ -120,7 +125,7 @@ export function FontFamilyCombobox({
/>
<CommandList>
<CommandEmpty>
{search.trim() ? (
{allowCustomEntry && search.trim() ? (
<button
type="button"
className="w-full text-center cursor-pointer hover:underline"
Expand All @@ -132,7 +137,7 @@ export function FontFamilyCombobox({
"No fonts found."
)}
</CommandEmpty>
{!hasExactMatch && search.trim() && (
{allowCustomEntry && !hasExactMatch && search.trim() && (
<CommandGroup heading="Custom">
<CommandItem
value={`__custom__${search.trim()}`}
Expand All @@ -144,9 +149,9 @@ export function FontFamilyCombobox({
</CommandItem>
</CommandGroup>
)}
{variant === "terminal" && renderGroup("Nerd Fonts", nerdFonts)}
{renderGroup("Nerd Fonts", nerdFonts)}
{renderGroup("Monospace", monoFonts)}
{renderGroup("Other", otherFonts)}
{variant !== "terminal" && renderGroup("Other", otherFonts)}
</CommandList>
</Command>
</PopoverContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,16 @@ export const webSearchTool = createTool({
},
});`;

const TERMINAL_PREVIEW = `\u256D\u2500 mastra agent \u2500\u2500 feat/add-tool \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E
\u2502 \u2713 Created inputSchema with zod \u2502
\u2502 \u2713 Wired execute handler \u2502
\u2502 \u2BFF Running tool integration tests... \u2502
\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F
\u256D\u2500 mastra agent \u2500\u2500 fix/workspace-sandbox \u2500\u2500\u256E
\u2502 \u2713 Patched LocalSandbox timeout \u2502
\u2502 \u2713 Updated workspace config \u2502
\u2502 \u2713 All 5 tests passing \u2502
\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F
\u256D\u2500 mastra agent \u2500\u2500 chore/mcp-server \u2500\u2500\u2500\u2500\u2500\u2500\u256E
\u2502 \u2BFF Registering tools with MCP server... \u2502
\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F
const TERMINAL_PREVIEW = `~/agent $ mastra dev
\u2192 Loaded 3 tools \u00B7 1 agent \u00B7 0 workflows
\u2192 Listening on http://localhost:4111

3 agents running \u00B7 2 workspaces \u00B7 8 files changed
~/agent $ mastra test
\u2713 web-search.test.ts (4) 47ms
\u2713 fetch-url.test.ts (7) 62ms
\u2713 researcher.test.ts (3) 91ms

Files 3 passed \u00B7 Tests 14 passed \u00B7 0.24s

Friends don't let friends compact.`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { memo, useEffect, useRef, useState } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { sanitizeTerminalFontFamily } from "renderer/lib/terminal/appearance";
import { buildTerminalCommand } from "renderer/lib/terminal/launch-command";
import { useTabsStore } from "renderer/stores/tabs/store";
import { useTerminalTheme } from "renderer/stores/theme";
import { SessionKilledOverlay } from "./components";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
} from "./config";
import { DEFAULT_TERMINAL_FONT_SIZE } from "./config";
import { getDefaultTerminalBg } from "./helpers";
import {
useFileLinkClick,
Expand Down Expand Up @@ -405,8 +403,7 @@ export const Terminal = memo(function Terminal({
// biome-ignore lint/correctness/useExhaustiveDependencies: resizeRef is a stable MutableRefObject — .current is read inside the effect, not a dependency
useEffect(() => {
if (!fontSettings) return;
const family =
fontSettings.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY;
const family = sanitizeTerminalFontFamily(fontSettings.terminalFontFamily);
const size = fontSettings.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
const result = v1TerminalCache.updateAppearance(paneId, family, size);
if (result?.changed) {
Expand Down
Loading