diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bb4c1de110..e6090aecbc 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -338,6 +338,9 @@ Features: - PTY-backed shell sessions - Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT - Ctrl/Cmd+V pastes clipboard text into the active terminal session +- The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, and Up/Down/Left/Right arrow buttons that send standard ANSI cursor sequences for keyboard-less shell history and line editing +- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically +- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts - Mobile-aware virtual keyboard handling and auto-refit behavior - Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required) diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index fa90f7d780..74578e7361 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -664,7 +664,8 @@ The symbols-only Nerd Font is listed first in the xterm font stack so powerline overflow-y: auto; } -.terminal-shortcut-modifier-row { +.terminal-shortcut-modifier-row, +.terminal-shortcut-arrow-row { display: flex; align-items: center; gap: var(--space-xs); @@ -672,6 +673,10 @@ The symbols-only Nerd Font is listed first in the xterm font stack so powerline margin-bottom: var(--space-xs); } +.terminal-shortcut-arrow-row { + justify-content: center; +} + .terminal-shortcut-btn { display: inline-flex; align-items: center; @@ -708,6 +713,45 @@ The symbols-only Nerd Font is listed first in the xterm font stack so powerline border-color: var(--in-progress); } +.terminal-preferences-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 4), 1fr)); + gap: var(--space-sm); + padding: var(--space-sm); + background: var(--surface); + border-top: 1px solid var(--border); +} + +.terminal-preference-field { + display: flex; + flex-direction: column; + gap: var(--space-xs); + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.terminal-preference-field--checkbox { + flex-direction: row; + align-items: center; + align-self: end; + color: var(--text); +} + +.terminal-preference-control { + width: 100%; + min-width: 0; +} + +.terminal-preference-note { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.terminal-preferences-reset { + align-self: end; + justify-self: start; +} + .terminal-status-bar { display: flex; align-items: center; @@ -982,6 +1026,17 @@ The symbols-only Nerd Font is listed first in the xterm font stack so powerline font-size: 11px; } + .terminal-preferences-panel { + grid-template-columns: 1fr; + max-height: calc(var(--space-2xl) * 8); + overflow-y: auto; + } + + .terminal-preference-field--checkbox, + .terminal-preferences-reset { + align-self: stretch; + } + .terminal-font-size-btn { min-width: calc(var(--space-xl) + var(--space-md)); min-height: calc(var(--space-xl) + var(--space-md)); diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 7490e63986..a4457b0294 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -10,11 +10,24 @@ import { Minus, Plus, Keyboard, + Settings, } from "lucide-react"; import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { getPathBasename } from "../utils/pathDisplay"; +import { + DEFAULT_TERMINAL_PREFERENCES, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_FAMILY_PRESETS, + clampTerminalFontSize, + readTerminalPreferences, + resolveTerminalFontFamily, + writeTerminalPreferences, + type TerminalPreferences, + type TerminalRenderer, +} from "../utils/terminalPreferences"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; @@ -24,12 +37,6 @@ import type { FitAddon } from "@xterm/addon-fit"; const XTERM_INIT_TIMEOUT_MS = 10000; const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const; -const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size"; -const DEFAULT_FONT_SIZE = 14; -const MIN_TERMINAL_FONT_SIZE = 8; -const MAX_TERMINAL_FONT_SIZE = 32; -const XTERM_FONT_FAMILY = - '"Fusion Terminal Nerd Font Symbols", "MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; export function ctrlChar(key: string): string { if (!key) { @@ -73,31 +80,12 @@ export const SHORTCUT_KEYS: ShortcutKey[] = [ { label: ".", key: ".", description: "Last argument" }, ]; -function clampTerminalFontSize(value: number): number { - return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value)); -} - -function readInitialTerminalFontSize(): number { - if (typeof window === "undefined") { - return DEFAULT_FONT_SIZE; - } - - try { - const savedFontSize = window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY); - if (!savedFontSize) { - return DEFAULT_FONT_SIZE; - } - - const parsed = Number.parseInt(savedFontSize, 10); - if (!Number.isFinite(parsed)) { - return DEFAULT_FONT_SIZE; - } - - return clampTerminalFontSize(parsed); - } catch { - return DEFAULT_FONT_SIZE; - } -} +const ARROW_SHORTCUT_KEYS = [ + { label: "↑", sequence: "\x1b[A", testId: "terminal-arrow-up", ariaLabel: "Send arrow up" }, + { label: "↓", sequence: "\x1b[B", testId: "terminal-arrow-down", ariaLabel: "Send arrow down" }, + { label: "←", sequence: "\x1b[D", testId: "terminal-arrow-left", ariaLabel: "Send arrow left" }, + { label: "→", sequence: "\x1b[C", testId: "terminal-arrow-right", ariaLabel: "Send arrow right" }, +] as const; function isRetryableDynamicImportError(error: unknown): boolean { const message = @@ -248,8 +236,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te const [openGeneration, setOpenGeneration] = useState(0); const [keyboardOverlap, setKeyboardOverlap] = useState(0); const [viewportHeight, setViewportHeight] = useState(null); - const [fontSize, setFontSize] = useState(() => readInitialTerminalFontSize()); + const [terminalPreferences, setTerminalPreferences] = useState(() => + readTerminalPreferences(), + ); + const fontSize = terminalPreferences.fontSize; + const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); const [showShortcuts, setShowShortcuts] = useState(false); + const [showPreferences, setShowPreferences] = useState(false); const [stickyModifier, setStickyModifier] = useState(null); const [pendingInitialCommandGeneration, setPendingInitialCommandGeneration] = useState(0); @@ -276,6 +269,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te const windowResizeListenerRef = useRef<(() => void) | null>(null); const keyboardOverlapRef = useRef(0); const fontSizeRef = useRef(fontSize); + const terminalPreferencesRef = useRef(terminalPreferences); + const resolvedFontFamilyRef = useRef(resolvedFontFamily); + const initializedRendererRef = useRef(terminalPreferences.renderer); /** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */ const pendingFitRef = useRef(null); /** Tracks the previous projectId to detect project switches and invalidate xterm. */ @@ -285,6 +281,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te // current mobile keyboard state without forcing the init effect to re-run. keyboardOverlapRef.current = keyboardOverlap; fontSizeRef.current = fontSize; + terminalPreferencesRef.current = terminalPreferences; + resolvedFontFamilyRef.current = resolvedFontFamily; latestInitialCommandRef.current = initialCommand; /** @@ -453,17 +451,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te resizeRef.current = resize; sendInputRef.current = sendInput; - useEffect(() => { - if (typeof window === "undefined") { - return; - } + const updateTerminalPreferences = useCallback((patch: Partial) => { + setTerminalPreferences((current) => writeTerminalPreferences({ ...current, ...patch })); + }, []); - try { - window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(fontSize)); - } catch { - // Ignore localStorage persistence errors. - } - }, [fontSize]); + const setFontSize = useCallback( + (value: number | ((current: number) => number)) => { + setTerminalPreferences((current) => { + const nextFontSize = + typeof value === "function" ? value(current.fontSize) : value; + return writeTerminalPreferences({ + ...current, + fontSize: clampTerminalFontSize(nextFontSize), + }); + }); + }, + [], + ); + + const resetTerminalPreferences = useCallback(() => { + setTerminalPreferences(writeTerminalPreferences(DEFAULT_TERMINAL_PREFERENCES)); + }, []); const refitTerminal = useCallback(() => { const terminal = xtermRef.current; @@ -490,7 +498,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te } try { - await document.fonts.load(`${fontSizeRef.current}px ${XTERM_FONT_FAMILY}`); + await document.fonts.load(`${fontSizeRef.current}px ${resolvedFontFamilyRef.current}`); await document.fonts.ready; } catch { // Font loading support is best-effort; keep the terminal usable if the @@ -512,7 +520,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te // fallback font after open(); re-applying font options and fitting after // FontFaceSet resolution forces the DOM/canvas and WebGL renderers to // remeasure against the actual glyph metrics. - terminal.options.fontFamily = XTERM_FONT_FAMILY; + terminal.options.fontFamily = resolvedFontFamilyRef.current; terminal.options.fontSize = fontSizeRef.current; fitAddon.fit(); resizeRef.current?.(terminal.cols, terminal.rows); @@ -589,12 +597,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te if (!mounted || !terminalRef.current || xtermRef.current) return; + const preferencesAtInit = terminalPreferencesRef.current; + const fontFamilyAtInit = resolvedFontFamilyRef.current; + // Create terminal instance terminal = new TerminalCtor({ - cursorBlink: true, - cursorStyle: "block", - fontSize: fontSizeRef.current, - fontFamily: XTERM_FONT_FAMILY, + cursorBlink: preferencesAtInit.cursorBlink, + cursorStyle: preferencesAtInit.cursorStyle, + fontSize: preferencesAtInit.fontSize, + fontFamily: fontFamilyAtInit, theme: { background: "#1e1e1e", foreground: "#d4d4d4", @@ -620,10 +631,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te const webLinksAddon = new WebLinksAddon(); terminal.loadAddon(webLinksAddon); - // Try to load WebGL addon for better performance - // Skip WebGL on mobile devices to avoid rendering artifacts (e.g., garbled - // Unicode characters in powerline prompt symbols on iOS Safari/WebKit). - if (!isMobileDevice()) { + initializedRendererRef.current = preferencesAtInit.renderer; + // Try to load WebGL addon for better performance. + // + // FNXC:Terminal 2026-06-16-23:45: + // Renderer preference may force canvas by skipping WebGL, but mobile remains a hard WebGL-off floor because WebKit glyph artifacts make terminal prompts unreadable on touch devices. + if (preferencesAtInit.renderer === "auto" && !isMobileDevice()) { try { const { WebglAddon } = await import("@xterm/addon-webgl"); const webglAddon = new WebglAddon(); @@ -824,6 +837,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te setError(null); setExitCode(null); setShowShortcuts(false); + setShowPreferences(false); setStickyModifier(null); }, [isOpen]); @@ -954,10 +968,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te return; } - xtermRef.current.options.fontSize = fontSize; + /* + FNXC:Terminal 2026-06-16-23:47: + Font and cursor preferences apply live to the active xterm so the preferences panel and status-bar zoom controls share one persisted source of truth. Renderer changes are intentionally deferred to the next terminal open because the WebGL addon is attached during xterm initialization. + */ + xtermRef.current.options.fontFamily = resolvedFontFamily; + xtermRef.current.options.fontSize = terminalPreferences.fontSize; + xtermRef.current.options.cursorStyle = terminalPreferences.cursorStyle; + xtermRef.current.options.cursorBlink = terminalPreferences.cursorBlink; // Defer fit until the next frame so layout reflects the new font metrics - // before FitAddon measures rows/cols. Reuse pendingFitRef so font-size and + // before FitAddon measures rows/cols. Reuse pendingFitRef so font changes and // visualViewport-triggered fits are coalesced into a single scheduled fit. if (pendingFitRef.current !== null) { cancelAnimationFrame(pendingFitRef.current); @@ -976,7 +997,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te pendingFitRef.current = null; } }; - }, [fontSize, xtermReady, refitTerminal]); + }, [resolvedFontFamily, terminalPreferences, xtermReady, refitTerminal]); // Handle keyboard shortcuts (zoom) useEffect(() => { @@ -1002,14 +1023,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te // Reset zoom: Ctrl/Cmd + 0 if (e.code === "Digit0" || e.code === "Numpad0") { e.preventDefault(); - setFontSize(DEFAULT_FONT_SIZE); + setFontSize(DEFAULT_TERMINAL_PREFERENCES.fontSize); return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, refitTerminal]); + }, [isOpen, setFontSize]); // Handle escape key to close useEffect(() => { @@ -1203,11 +1224,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te const handleIncreaseFontSize = useCallback(() => { setFontSize((current) => clampTerminalFontSize(current + 1)); - }, []); + }, [setFontSize]); const handleDecreaseFontSize = useCallback(() => { setFontSize((current) => clampTerminalFontSize(current - 1)); - }, []); + }, [setFontSize]); + + const handlePreferenceFontSizeChange = useCallback( + (value: string) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return; + } + setFontSize(parsed); + }, + [setFontSize], + ); const toggleModifier = useCallback((modifier: "ctrl" | "alt") => { setStickyModifier((current) => (current === modifier ? null : modifier)); @@ -1381,6 +1413,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te {t("terminal.shortcuts", "Shortcuts")} + + ))} + {SHORTCUT_KEYS.map((shortcut) => ( + + )} + {/* Connection status bar */}
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index c20177f556..dd7fb5a6b4 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -5,6 +5,12 @@ FN-6441 rescued this orphaned component test after standalone dashboard-app exec import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar } from "../TerminalModal"; +import { + DEFAULT_TERMINAL_PREFERENCES, + LEGACY_TERMINAL_FONT_SIZE_KEY, + TERMINAL_PREFERENCES_KEY, + XTERM_FONT_FAMILY, +} from "../../utils/terminalPreferences"; import * as useTerminalModule from "../../hooks/useTerminal"; import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions"; import * as apiModule from "../../api"; @@ -87,7 +93,7 @@ const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal); const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions); const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession); const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession); -const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size"; +const TERMINAL_FONT_SIZE_KEY = LEGACY_TERMINAL_FONT_SIZE_KEY; describe("ctrlChar/altChar helpers", () => { it("maps Ctrl+C/D/Z/L and Alt sequences correctly", () => { @@ -185,7 +191,11 @@ describe("TerminalModal", () => { configurable: true, }); window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY); + window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY); + mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY; mockTerminalInstance.options.fontSize = 14; + mockTerminalInstance.options.cursorStyle = "block"; + mockTerminalInstance.options.cursorBlink = true; mockCreateTerminalSession.mockResolvedValue({ sessionId: "test-session-123", shell: "/bin/bash", @@ -624,8 +634,7 @@ describe("TerminalModal", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - fontFamily: - '"Fusion Terminal Nerd Font Symbols", "MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + fontFamily: XTERM_FONT_FAMILY, }), ); expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px"); @@ -692,6 +701,24 @@ describe("TerminalModal", () => { expect(mockSendInput).toHaveBeenCalledWith("\t"); }); + it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => { + render(); + + fireEvent.click(screen.getByTestId("terminal-shortcut-toggle")); + fireEvent.click(screen.getByTestId("terminal-modifier-ctrl")); + + fireEvent.click(screen.getByTestId("terminal-arrow-up")); + fireEvent.click(screen.getByTestId("terminal-arrow-down")); + fireEvent.click(screen.getByTestId("terminal-arrow-left")); + fireEvent.click(screen.getByTestId("terminal-arrow-right")); + + expect(mockSendInput).toHaveBeenNthCalledWith(1, "\x1b[A"); + expect(mockSendInput).toHaveBeenNthCalledWith(2, "\x1b[B"); + expect(mockSendInput).toHaveBeenNthCalledWith(3, "\x1b[D"); + expect(mockSendInput).toHaveBeenNthCalledWith(4, "\x1b[C"); + expect(screen.getByTestId("terminal-modifier-ctrl").getAttribute("aria-pressed")).toBe("false"); + }); + it("renders shortcut controls on mobile viewport", async () => { const previousInnerWidth = window.innerWidth; const previousOntouchstart = window.ontouchstart; @@ -847,6 +874,108 @@ describe("TerminalModal", () => { }); }); + describe("terminal preferences", () => { + it("toggles the preferences panel", () => { + render(); + + expect(screen.queryByTestId("terminal-preferences-panel")).toBeNull(); + + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + expect(screen.getByTestId("terminal-preferences-panel")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + expect(screen.queryByTestId("terminal-preferences-panel")).toBeNull(); + }); + + it("persists preference changes and applies live xterm options", async () => { + render(); + + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + + fireEvent.change(screen.getByTestId("terminal-preference-font-family"), { + target: { value: "system-mono" }, + }); + fireEvent.change(screen.getByTestId("terminal-preference-cursor-style"), { + target: { value: "underline" }, + }); + fireEvent.click(screen.getByTestId("terminal-preference-cursor-blink")); + + await waitFor(() => { + expect(mockTerminalInstance.options.fontFamily).toContain("ui-monospace"); + expect(mockTerminalInstance.options.cursorStyle).toBe("underline"); + expect(mockTerminalInstance.options.cursorBlink).toBe(false); + }); + + const persisted = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null"); + expect(persisted).toEqual({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontFamily: "system-mono", + cursorStyle: "underline", + cursorBlink: false, + }); + }); + + it("resets preferences to defaults", async () => { + render(); + + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + fireEvent.change(screen.getByTestId("terminal-preference-font-size"), { + target: { value: "21" }, + }); + + await waitFor(() => { + expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("21px"); + }); + + fireEvent.click(screen.getByTestId("terminal-preferences-reset")); + + await waitFor(() => { + expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px"); + expect(screen.getByTestId("terminal-preference-font-size")).toHaveProperty("value", "14"); + }); + expect(JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual( + DEFAULT_TERMINAL_PREFERENCES, + ); + }); + + it("keeps panel font-size control and status-bar controls in sync", async () => { + render(); + + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + fireEvent.change(screen.getByTestId("terminal-preference-font-size"), { + target: { value: "16" }, + }); + + await waitFor(() => { + expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("16px"); + }); + + fireEvent.click(screen.getByTestId("terminal-font-size-increase")); + + await waitFor(() => { + expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("17px"); + expect(screen.getByTestId("terminal-preference-font-size")).toHaveProperty("value", "17"); + }); + }); + + it("shows renderer changes as next-open only", async () => { + render(); + + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId("terminal-preferences-toggle")); + expect(screen.queryByTestId("terminal-renderer-reopen-note")).toBeNull(); + + fireEvent.change(screen.getByTestId("terminal-preference-renderer"), { + target: { value: "canvas" }, + }); + + await waitFor(() => { + expect(screen.getByTestId("terminal-renderer-reopen-note")).toBeTruthy(); + }); + }); + }); + it("xterm container is rendered (visible under loading overlay) while loading", async () => { mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, diff --git a/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts new file mode 100644 index 0000000000..7337a4157c --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_TERMINAL_PREFERENCES, + LEGACY_TERMINAL_FONT_SIZE_KEY, + TERMINAL_PREFERENCES_KEY, + readTerminalPreferences, + writeTerminalPreferences, +} from "../terminalPreferences"; + +describe("terminalPreferences", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns defaults when storage is empty", () => { + expect(readTerminalPreferences()).toEqual(DEFAULT_TERMINAL_PREFERENCES); + }); + + it("falls back to defaults for corrupt JSON", () => { + localStorage.setItem(TERMINAL_PREFERENCES_KEY, "not-json"); + + expect(readTerminalPreferences()).toEqual(DEFAULT_TERMINAL_PREFERENCES); + }); + + it("clamps font size values", () => { + localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 99 }), + ); + expect(readTerminalPreferences().fontSize).toBe(32); + + localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 1 }), + ); + expect(readTerminalPreferences().fontSize).toBe(8); + }); + + it("rejects unknown enum values to defaults", () => { + localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ + fontFamily: "comic-sans", + fontSize: 16, + cursorStyle: "boxy", + cursorBlink: false, + renderer: "webgl-only", + }), + ); + + expect(readTerminalPreferences()).toEqual({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontSize: 16, + cursorBlink: false, + }); + }); + + it("migrates the legacy font-size key on first read", () => { + localStorage.setItem(LEGACY_TERMINAL_FONT_SIZE_KEY, "20"); + + expect(readTerminalPreferences()).toEqual({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontSize: 20, + }); + expect(JSON.parse(localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontSize: 20, + }); + }); + + it("round-trips normalized writes", () => { + const written = writeTerminalPreferences({ + fontFamily: "system-mono", + fontSize: 22, + cursorStyle: "underline", + cursorBlink: false, + renderer: "canvas", + }); + + expect(written).toEqual({ + fontFamily: "system-mono", + fontSize: 22, + cursorStyle: "underline", + cursorBlink: false, + renderer: "canvas", + }); + expect(readTerminalPreferences()).toEqual(written); + expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22"); + }); +}); diff --git a/packages/dashboard/app/utils/terminalPreferences.ts b/packages/dashboard/app/utils/terminalPreferences.ts new file mode 100644 index 0000000000..feb40d1a7c --- /dev/null +++ b/packages/dashboard/app/utils/terminalPreferences.ts @@ -0,0 +1,197 @@ +export const TERMINAL_PREFERENCES_KEY = "kb-terminal-preferences"; +export const LEGACY_TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size"; +export const DEFAULT_TERMINAL_FONT_SIZE = 14; +export const MIN_TERMINAL_FONT_SIZE = 8; +export const MAX_TERMINAL_FONT_SIZE = 32; + +export const XTERM_FONT_FAMILY = + '"Fusion Terminal Nerd Font Symbols", "MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; + +export const TERMINAL_FONT_FAMILY_PRESETS = [ + { + id: "nerd-font", + label: "Nerd Font stack", + css: XTERM_FONT_FAMILY, + }, + { + id: "system-mono", + label: "System monospace", + css: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + }, + { + id: "jetbrains-mono", + label: "JetBrains Mono", + css: '"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace', + }, + { + id: "fira-code", + label: "Fira Code", + css: '"Fira Code", "FiraCode Nerd Font", ui-monospace, SFMono-Regular, monospace', + }, +] as const; + +export type TerminalFontFamily = (typeof TERMINAL_FONT_FAMILY_PRESETS)[number]["id"]; +export type TerminalCursorStyle = "block" | "underline" | "bar"; +export type TerminalRenderer = "auto" | "canvas"; + +export interface TerminalPreferences { + fontFamily: TerminalFontFamily; + fontSize: number; + cursorStyle: TerminalCursorStyle; + cursorBlink: boolean; + renderer: TerminalRenderer; +} + +/* +FNXC:Terminal 2026-06-16-23:35: +Terminal preferences are intentionally client-local: users can customize font, cursor, and renderer without introducing server settings schema. Reads must tolerate unavailable storage, corrupt JSON, unknown enum values, and legacy font-size data so opening the terminal never throws and always falls back to safe defaults. +*/ +export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { + fontFamily: "nerd-font", + fontSize: DEFAULT_TERMINAL_FONT_SIZE, + cursorStyle: "block", + cursorBlink: true, + renderer: "auto", +}; + +export function clampTerminalFontSize(value: number): number { + return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value)); +} + +export function resolveTerminalFontFamily(fontFamily: TerminalFontFamily): string { + return ( + TERMINAL_FONT_FAMILY_PRESETS.find((preset) => preset.id === fontFamily)?.css ?? + XTERM_FONT_FAMILY + ); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isTerminalFontFamily(value: unknown): value is TerminalFontFamily { + return ( + typeof value === "string" && + TERMINAL_FONT_FAMILY_PRESETS.some((preset) => preset.id === value) + ); +} + +function isTerminalCursorStyle(value: unknown): value is TerminalCursorStyle { + return value === "block" || value === "underline" || value === "bar"; +} + +function isTerminalRenderer(value: unknown): value is TerminalRenderer { + return value === "auto" || value === "canvas"; +} + +function readLegacyFontSize(): number | undefined { + if (typeof window === "undefined") { + return undefined; + } + + try { + const savedFontSize = window.localStorage?.getItem?.(LEGACY_TERMINAL_FONT_SIZE_KEY); + if (!savedFontSize) { + return undefined; + } + + const parsed = Number.parseInt(savedFontSize, 10); + if (!Number.isFinite(parsed)) { + return undefined; + } + + return clampTerminalFontSize(parsed); + } catch { + return undefined; + } +} + +function normalizeTerminalPreferences(value: unknown): TerminalPreferences { + const source = isObject(value) ? value : {}; + const rawFontSize = source.fontSize; + const parsedFontSize = + typeof rawFontSize === "number" + ? rawFontSize + : typeof rawFontSize === "string" + ? Number.parseInt(rawFontSize, 10) + : Number.NaN; + + return { + fontFamily: isTerminalFontFamily(source.fontFamily) + ? source.fontFamily + : DEFAULT_TERMINAL_PREFERENCES.fontFamily, + fontSize: Number.isFinite(parsedFontSize) + ? clampTerminalFontSize(parsedFontSize) + : DEFAULT_TERMINAL_PREFERENCES.fontSize, + cursorStyle: isTerminalCursorStyle(source.cursorStyle) + ? source.cursorStyle + : DEFAULT_TERMINAL_PREFERENCES.cursorStyle, + cursorBlink: + typeof source.cursorBlink === "boolean" + ? source.cursorBlink + : DEFAULT_TERMINAL_PREFERENCES.cursorBlink, + renderer: isTerminalRenderer(source.renderer) + ? source.renderer + : DEFAULT_TERMINAL_PREFERENCES.renderer, + }; +} + +export function readTerminalPreferences(): TerminalPreferences { + if (typeof window === "undefined") { + return { ...DEFAULT_TERMINAL_PREFERENCES }; + } + + try { + const savedPreferences = window.localStorage?.getItem?.(TERMINAL_PREFERENCES_KEY); + if (savedPreferences) { + return normalizeTerminalPreferences(JSON.parse(savedPreferences)); + } + + const legacyFontSize = readLegacyFontSize(); + if (legacyFontSize === undefined) { + return { ...DEFAULT_TERMINAL_PREFERENCES }; + } + + const migratedPreferences = { + ...DEFAULT_TERMINAL_PREFERENCES, + fontSize: legacyFontSize, + }; + window.localStorage?.setItem?.( + TERMINAL_PREFERENCES_KEY, + JSON.stringify(migratedPreferences), + ); + return migratedPreferences; + } catch { + return { ...DEFAULT_TERMINAL_PREFERENCES }; + } +} + +export function writeTerminalPreferences( + patch: Partial, +): TerminalPreferences { + const nextPreferences = normalizeTerminalPreferences({ + ...readTerminalPreferences(), + ...patch, + }); + + if (typeof window === "undefined") { + return nextPreferences; + } + + try { + window.localStorage?.setItem?.( + TERMINAL_PREFERENCES_KEY, + JSON.stringify(nextPreferences), + ); + // Keep the retired scalar value in sync for any stale tab still reading it + // while this deployment is hot-reloaded. + window.localStorage?.setItem?.( + LEGACY_TERMINAL_FONT_SIZE_KEY, + String(nextPreferences.fontSize), + ); + } catch { + // Ignore persistence failures; callers still receive the normalized live value. + } + + return nextPreferences; +}