diff --git a/experimental/papyrus/client/src/components/SettingsPanel.tsx b/experimental/papyrus/client/src/components/SettingsPanel.tsx
index aeb53a4..ed23c5d 100644
--- a/experimental/papyrus/client/src/components/SettingsPanel.tsx
+++ b/experimental/papyrus/client/src/components/SettingsPanel.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X, Minus, Plus, TerminalSquare, Ghost, type LucideIcon } from "lucide-react";
import { useStore } from "../stores/useStore";
@@ -5,6 +6,11 @@ import {
LINE_SPACING_STEP,
MIN_LINE_SPACING,
MAX_LINE_SPACING,
+ FONT_SIZE_STEP,
+ MIN_FONT_SIZE,
+ MAX_FONT_SIZE,
+ clampFontSize,
+ fontSizeFor,
TERMINAL_BACKENDS,
TERMINAL_FONTS,
type BackendId,
@@ -44,6 +50,82 @@ function FontPicker({ value, onChange }: { value: FontId; onChange: (id: FontId)
);
}
+// A −/+ stepper with an editable number in the middle. `value` is the committed size; `onCommit`
+// clamps and persists. The text buffer lets the field go empty/partial mid-edit; it re-syncs to
+// `value` whenever that changes externally (font/emulator switch, or a clamp on commit), and
+// commits on blur/Enter — reverting to `value` on empty or non-numeric input.
+function FontSizeStepper({ value, onCommit }: { value: number; onCommit: (n: number) => void }) {
+ const [text, setText] = useState(String(value));
+ useEffect(() => {
+ setText(String(value));
+ }, [value]);
+
+ const atMin = value <= MIN_FONT_SIZE;
+ const atMax = value >= MAX_FONT_SIZE;
+
+ // Commit the field's live DOM value (not the `text` closure, which can lag a blur that lands in
+ // the same tick as the edit). Always re-sync the buffer to the committed value: clamping here
+ // (not just in onCommit) keeps the field honest even when the clamped result equals the current
+ // `value` (so the re-sync effect below wouldn't fire). Empty/NaN reverts to the current value.
+ const commit = (raw: string) => {
+ const n = Number(raw);
+ if (raw.trim() !== "" && Number.isFinite(n)) {
+ const clamped = clampFontSize(n);
+ onCommit(clamped);
+ setText(String(clamped));
+ } else {
+ setText(String(value));
+ }
+ };
+
+ return (
+
+ );
+}
+
// Global appearance settings, docked to the right edge and toggled by the header gear.
// Mirrors Sidebar's slide-in; sits above it (z-[60]) so the two never collide. Holds the
// emulator backend selector, then a section of options for the selected emulator only — a
@@ -60,6 +142,8 @@ export function SettingsPanel() {
const setXtermFont = useStore((s) => s.setXtermFont);
const ghosttyFont = useStore((s) => s.ghosttyFont);
const setGhosttyFont = useStore((s) => s.setGhosttyFont);
+ const fontSizes = useStore((s) => s.fontSizes);
+ const setFontSize = useStore((s) => s.setFontSize);
const atMin = lineSpacing <= MIN_LINE_SPACING;
const atMax = lineSpacing >= MAX_LINE_SPACING;
@@ -133,6 +217,13 @@ export function SettingsPanel() {
Font
+
+ Font size for selected font
+ setFontSize("xterm", xtermFont, n)}
+ />
+
+ Font size for selected font
+ setFontSize("ghostty", ghosttyFont, n)}
+ />
+
+ >
)}
diff --git a/experimental/papyrus/client/src/components/Terminal.tsx b/experimental/papyrus/client/src/components/Terminal.tsx
index 6ea1198..2ad7d9a 100644
--- a/experimental/papyrus/client/src/components/Terminal.tsx
+++ b/experimental/papyrus/client/src/components/Terminal.tsx
@@ -3,7 +3,7 @@ import { useStore } from "../stores/useStore";
import { terminalWsUrl } from "./terminalWs";
import { createTerminalBackend, type TerminalBackend } from "../terminal/backend";
import { terminalTheme } from "../terminal/palette";
-import { fontStack } from "../settings";
+import { fontStack, fontSizeFor } from "../settings";
interface TerminalProps {
sessionId: string;
@@ -25,15 +25,20 @@ export function Terminal({ sessionId, color }: TerminalProps) {
const backendId = useStore((s) => s.terminalBackend);
const xtermFont = useStore((s) => s.xtermFont);
const ghosttyFont = useStore((s) => s.ghosttyFont);
+ const fontSizes = useStore((s) => s.fontSizes);
// Font is per-emulator; the active pane renders the current backend's font.
const activeFont = backendId === "xterm" ? xtermFont : ghosttyFont;
+ // Font size is per (emulator, font) pair; resolve the current pair's size.
+ const activeFontSize = fontSizeFor(fontSizes, backendId, activeFont);
- // Backends that can't mutate theme/line-height/font live (ghostty) fold those into a
+ // Backends that can't mutate theme/line-height/font/size live (ghostty) fold those into a
// remount token, so changing any of them rebuilds the pane at the right settings. xterm
- // applies all three live, so its token is constant and its rebuild triggers stay the old deps.
+ // applies them all live, so its token is constant and its rebuild triggers stay the old deps.
const remountToken =
- backendId === "xterm" ? "" : `${resolvedTheme}:${lineSpacing}:${ghosttyFont}`;
+ backendId === "xterm"
+ ? ""
+ : `${resolvedTheme}:${lineSpacing}:${ghosttyFont}:${activeFontSize}`;
useEffect(() => {
const container = terminalRef.current;
@@ -56,7 +61,13 @@ export function Terminal({ sessionId, color }: TerminalProps) {
const created = await createTerminalBackend(backendId, {
cursorBlink: true,
cursorStyle: "bar",
- fontSize: 12,
+ // Read non-reactively; live size changes are handled by the setFontSize effect (xterm) or
+ // the remount token (ghostty), not by tearing down here.
+ fontSize: fontSizeFor(
+ useStore.getState().fontSizes,
+ backendId,
+ backendId === "xterm" ? useStore.getState().xtermFont : useStore.getState().ghosttyFont,
+ ),
// Read non-reactively; live font changes are handled by the setFont effect (xterm) or
// the remount token (ghostty), not by tearing down here.
fontFamily: fontStack(
@@ -195,6 +206,21 @@ export function Terminal({ sessionId, color }: TerminalProps) {
}
}, [activeFont]);
+ // Apply font-size changes live for the active (emulator, font) pair. Like font/line-spacing, a
+ // new size changes cell metrics → row/col count, so refit and tell the PTY. Backends without
+ // live support (ghostty) return false and are handled by the remount token instead.
+ useEffect(() => {
+ const backend = backendRef.current;
+ if (backend && backend.setFontSize(activeFontSize)) {
+ backend.fit();
+ const ws = wsRef.current;
+ const size = backend.size;
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows }));
+ }
+ }
+ }, [activeFontSize]);
+
return (
{
expect(fontStack(DEFAULT_FONT)).toBe('"JetBrains Mono", monospace');
});
});
+
+describe("clampFontSize", () => {
+ test("clamps below the minimum up to MIN", () => {
+ expect(clampFontSize(1)).toBe(MIN_FONT_SIZE);
+ });
+
+ test("clamps above the maximum down to MAX", () => {
+ expect(clampFontSize(999)).toBe(MAX_FONT_SIZE);
+ });
+
+ test("rounds to a whole pixel", () => {
+ expect(clampFontSize(13.4)).toBe(13);
+ expect(clampFontSize(13.6)).toBe(14);
+ });
+
+ test("passes a valid whole value through unchanged", () => {
+ expect(clampFontSize(16)).toBe(16);
+ });
+
+ test("non-finite input falls back to the default", () => {
+ expect(clampFontSize(NaN)).toBe(DEFAULT_FONT_SIZE);
+ expect(clampFontSize(Infinity)).toBe(DEFAULT_FONT_SIZE);
+ });
+});
+
+describe("parseFontSizes", () => {
+ test("null (nothing stored) yields an empty map", () => {
+ expect(parseFontSizes(null)).toEqual({});
+ });
+
+ test("corrupt JSON or a non-object yields an empty map rather than throwing", () => {
+ expect(parseFontSizes("not json")).toEqual({});
+ expect(parseFontSizes("42")).toEqual({});
+ expect(parseFontSizes("null")).toEqual({});
+ });
+
+ test("drops entries whose value isn't a finite number", () => {
+ expect(parseFontSizes('{"xterm:iosevka":"big","ghostty:iosevka":15}')).toEqual({
+ "ghostty:iosevka": 15,
+ });
+ });
+
+ test("clamps surviving values into range", () => {
+ expect(parseFontSizes('{"xterm:iosevka":999}')).toEqual({
+ "xterm:iosevka": MAX_FONT_SIZE,
+ });
+ });
+});
+
+describe("loadFontSizes / saveFontSizes", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ test("defaults to an empty map when nothing is stored", () => {
+ expect(loadFontSizes()).toEqual({});
+ });
+
+ test("persists the whole map under the papyrus:fontSizes key and reads back", () => {
+ const map = { "xterm:iosevka": 14, "ghostty:jetbrains-mono": 18 };
+ saveFontSizes(map);
+ expect(localStorage.getItem(FONT_SIZE_STORAGE_KEY)).toBe(JSON.stringify(map));
+ expect(loadFontSizes()).toEqual(map);
+ });
+
+ test("a corrupt stored value loads as an empty map", () => {
+ localStorage.setItem(FONT_SIZE_STORAGE_KEY, "{bogus");
+ expect(loadFontSizes()).toEqual({});
+ });
+});
+
+describe("fontSizeFor / fontSizeKey", () => {
+ test("keys a pair as `${backend}:${font}`", () => {
+ expect(fontSizeKey("xterm", "iosevka")).toBe("xterm:iosevka");
+ });
+
+ test("an unset pair falls back to the default size", () => {
+ expect(fontSizeFor({}, "xterm", "iosevka")).toBe(DEFAULT_FONT_SIZE);
+ });
+
+ test("each (emulator, font) pair resolves its own stored size", () => {
+ const map = { "xterm:iosevka": 14, "ghostty:iosevka": 20, "xterm:jetbrains-mono": 11 };
+ expect(fontSizeFor(map, "xterm", "iosevka")).toBe(14);
+ expect(fontSizeFor(map, "ghostty", "iosevka")).toBe(20);
+ expect(fontSizeFor(map, "xterm", "jetbrains-mono")).toBe(11);
+ // A pair absent from the map still defaults.
+ expect(fontSizeFor(map, "ghostty", "jetbrains-mono")).toBe(DEFAULT_FONT_SIZE);
+ });
+});
diff --git a/experimental/papyrus/client/src/settings.ts b/experimental/papyrus/client/src/settings.ts
index 1a7abed..0d3cd47 100644
--- a/experimental/papyrus/client/src/settings.ts
+++ b/experimental/papyrus/client/src/settings.ts
@@ -135,3 +135,66 @@ export function saveFont(storageKey: string, id: FontId): void {
export function fontStack(id: FontId): string {
return (TERMINAL_FONTS.find((f) => f.id === id) ?? TERMINAL_FONTS[0]).stack;
}
+
+// --- Terminal font size ---
+//
+// Font size in px, remembered independently per (emulator, font) pair — different fonts render
+// at different apparent sizes, so each combination keeps its own value. Persisted as a single
+// JSON map under one key, keyed by `${backend}:${font}`; a missing pair falls back to
+// DEFAULT_FONT_SIZE. Values are clamped to a sane px range and rounded to whole pixels.
+
+export const FONT_SIZE_STORAGE_KEY = "papyrus:fontSizes";
+
+export const MIN_FONT_SIZE = 6;
+export const MAX_FONT_SIZE = 40;
+export const FONT_SIZE_STEP = 1;
+// 12 is what xterm/ghostty panes were previously hardcoded to.
+export const DEFAULT_FONT_SIZE = 12;
+
+// The map key for an (emulator, font) pair.
+export function fontSizeKey(backend: BackendId, font: FontId): string {
+ return `${backend}:${font}`;
+}
+
+// Clamp to [MIN, MAX] and round to a whole pixel. Non-finite input falls back to the default.
+export function clampFontSize(n: number): number {
+ if (!Number.isFinite(n)) return DEFAULT_FONT_SIZE;
+ return Math.round(Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, n)));
+}
+
+// A lenient parse: corrupt JSON, a non-object, or entries whose value isn't a finite number are
+// dropped (they fall back to DEFAULT_FONT_SIZE at read). Surviving values are clamped.
+export function parseFontSizes(raw: string | null): Record {
+ if (raw === null) return {};
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object") return {};
+ const out: Record = {};
+ for (const [k, v] of Object.entries(parsed as Record)) {
+ if (typeof v === "number" && Number.isFinite(v)) out[k] = clampFontSize(v);
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+export function loadFontSizes(): Record {
+ if (typeof localStorage === "undefined") return {};
+ return parseFontSizes(localStorage.getItem(FONT_SIZE_STORAGE_KEY));
+}
+
+export function saveFontSizes(map: Record): void {
+ if (typeof localStorage === "undefined") return;
+ localStorage.setItem(FONT_SIZE_STORAGE_KEY, JSON.stringify(map));
+}
+
+// The effective size for an (emulator, font) pair, defaulting when unset.
+export function fontSizeFor(
+ map: Record,
+ backend: BackendId,
+ font: FontId,
+): number {
+ const v = map[fontSizeKey(backend, font)];
+ return typeof v === "number" && Number.isFinite(v) ? clampFontSize(v) : DEFAULT_FONT_SIZE;
+}
diff --git a/experimental/papyrus/client/src/stores/useStore.ts b/experimental/papyrus/client/src/stores/useStore.ts
index d458c85..410bb4d 100644
--- a/experimental/papyrus/client/src/stores/useStore.ts
+++ b/experimental/papyrus/client/src/stores/useStore.ts
@@ -13,11 +13,15 @@ import {
type FontId,
GHOSTTY_FONT_STORAGE_KEY,
XTERM_FONT_STORAGE_KEY,
+ clampFontSize,
clampLineSpacing,
+ fontSizeKey,
loadFont,
+ loadFontSizes,
loadLineSpacing,
loadTerminalBackend,
saveFont,
+ saveFontSizes,
saveLineSpacing,
saveTerminalBackend,
} from "../settings";
@@ -136,6 +140,9 @@ interface AppState {
setXtermFont: (id: FontId) => void;
ghosttyFont: FontId;
setGhosttyFont: (id: FontId) => void;
+ // Font size (px) is per (emulator, font) pair, keyed by `${backend}:${font}` in this map.
+ fontSizes: Record;
+ setFontSize: (backend: BackendId, font: FontId, size: number) => void;
}
export const useStore = create((set) => ({
@@ -236,4 +243,12 @@ export const useStore = create((set) => ({
saveFont(GHOSTTY_FONT_STORAGE_KEY, id);
set({ ghosttyFont: id });
},
+ // Terminal reads the active (emulator, font) pair's size live (xterm) or via a remount (ghostty).
+ fontSizes: loadFontSizes(),
+ setFontSize: (backend, font, size) =>
+ set((state) => {
+ const next = { ...state.fontSizes, [fontSizeKey(backend, font)]: clampFontSize(size) };
+ saveFontSizes(next);
+ return { fontSizes: next };
+ }),
}));
diff --git a/experimental/papyrus/client/src/terminal/backend.ts b/experimental/papyrus/client/src/terminal/backend.ts
index 39b8c1a..112da5b 100644
--- a/experimental/papyrus/client/src/terminal/backend.ts
+++ b/experimental/papyrus/client/src/terminal/backend.ts
@@ -46,6 +46,7 @@ export interface TerminalBackend {
setTheme(themeName: ThemeName, cursorColor: string): boolean;
setLineHeight(value: number): boolean;
setFont(fontFamily: string): boolean;
+ setFontSize(px: number): boolean;
dispose(): void;
}
diff --git a/experimental/papyrus/client/src/terminal/ghosttyBackend.ts b/experimental/papyrus/client/src/terminal/ghosttyBackend.ts
index 3f6b5c6..f5b3569 100644
--- a/experimental/papyrus/client/src/terminal/ghosttyBackend.ts
+++ b/experimental/papyrus/client/src/terminal/ghosttyBackend.ts
@@ -101,10 +101,10 @@ class GhosttyBackend implements TerminalBackend {
return true;
}
- // Line-height and font can't mutate live: Terminal.tsx folds them into its remount token,
- // rebuilding the pane with the new settings baked into the constructor. A font remount re-runs
- // createGhosttyBackend (awaits document.fonts.ready) and open() (re-measures the cell via
- // roundCellWidth), so the new font is sized correctly.
+ // Line-height, font, and font size can't mutate live: Terminal.tsx folds them into its remount
+ // token, rebuilding the pane with the new settings baked into the constructor. A font/size
+ // remount re-runs createGhosttyBackend (awaits document.fonts.ready) and open() (re-measures the
+ // cell via roundCellWidth), so the new font is sized correctly.
setLineHeight(): boolean {
return false;
}
@@ -113,6 +113,10 @@ class GhosttyBackend implements TerminalBackend {
return false;
}
+ setFontSize(): boolean {
+ return false;
+ }
+
dispose() {
this.term.dispose();
}
diff --git a/experimental/papyrus/client/src/terminal/xtermBackend.ts b/experimental/papyrus/client/src/terminal/xtermBackend.ts
index 914f4a4..60fd2af 100644
--- a/experimental/papyrus/client/src/terminal/xtermBackend.ts
+++ b/experimental/papyrus/client/src/terminal/xtermBackend.ts
@@ -68,6 +68,11 @@ class XtermBackend implements TerminalBackend {
return true;
}
+ setFontSize(px: number): boolean {
+ this.term.options.fontSize = px;
+ return true;
+ }
+
dispose() {
this.term.dispose();
}