From bd328f22822c49dfe0665fcca111da73a8db59d2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 01:15:20 -0700 Subject: [PATCH] FN-6536: apply terminal preferences to sessions Extend embedded session terminals to reuse the saved TerminalModal preferences while preserving replay safety. - Apply shared font, font size, cursor style, cursor blink, and renderer preferences when SessionTerminal initializes. - Live-apply font and cursor preference changes from storage events without remounting the terminal. - Keep cursor blink disabled for read-only, idle, and ended sessions, and skip WebGL on mobile viewports. - Add desktop and mobile tests plus dashboard documentation for embedded session preference behavior. Files changed: docs/dashboard-guide.md | 1 + .../dashboard/app/components/SessionTerminal.tsx | 96 +++++++++--- .../__tests__/SessionTerminal.mobile.test.tsx | 53 ++++++- .../components/__tests__/SessionTerminal.test.tsx | 166 +++++++++++++++++++-- 4 files changed, 286 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-6536 Fusion-Task-Lineage: 836eafd7-923d-45b7-9cf6-e1236af8f168 --- docs/dashboard-guide.md | 1 + .../app/components/SessionTerminal.tsx | 96 ++++++++-- .../__tests__/SessionTerminal.mobile.test.tsx | 53 +++++- .../__tests__/SessionTerminal.test.tsx | 166 +++++++++++++++++- 4 files changed, 286 insertions(+), 30 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e6090aecbc..23c14d8b14 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -341,6 +341,7 @@ Features: - 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 +- Embedded CLI session terminals honor the same saved preferences for live, idle, ended, read-only, and interactive session views. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports. - 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/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 6011b5893e..43e624ea24 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -8,6 +8,11 @@ import { appendTokenQuery } from "../auth"; import { api } from "../api"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; +import { + TERMINAL_PREFERENCES_KEY, + readTerminalPreferences, + resolveTerminalFontFamily, +} from "../utils/terminalPreferences"; /** * SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI @@ -249,6 +254,45 @@ export function SessionTerminal({ if (showConfirmAdvance) setAdvanceDismissed(false); }, [showConfirmAdvance, sessionId]); + const applyLiveTerminalPreferences = useCallback(() => { + const terminal = xtermRef.current; + if (!terminal) { + return; + } + + const terminalPreferences = readTerminalPreferences(); + terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + terminal.options.fontSize = terminalPreferences.fontSize; + terminal.options.cursorStyle = terminalPreferences.cursorStyle; + terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live"; + + try { + (fitAddonRef.current as { fit?: () => void } | null)?.fit?.(); + } catch { + /* ignore transient measure failures */ + } + }, [mode, readOnly]); + + /* + FNXC:Terminal 2026-06-17-01:05: + Font and cursor preferences live-apply through the shared storage key so SessionTerminal follows changes made in another terminal surface without remounting. Renderer remains excluded from this handler because renderer addon teardown/re-attach only happens safely during the next session init. + */ + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== TERMINAL_PREFERENCES_KEY) { + return; + } + applyLiveTerminalPreferences(); + }; + + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [applyLiveTerminalPreferences]); + // ── xterm lifecycle + WS bridge ────────────────────────────────────────── useEffect(() => { if (!sessionId || typeof window === "undefined") return; @@ -295,16 +339,23 @@ export function SessionTerminal({ ]); if (disposed || !containerRef.current) return; + const terminalPreferences = readTerminalPreferences(); + const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + + /* + FNXC:Terminal 2026-06-17-00:50: + SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink. + */ const term = new Terminal({ convertEol: false, - cursorBlink: !readOnly && mode === "live", + cursorBlink: terminalPreferences.cursorBlink && !readOnly && mode === "live", + cursorStyle: terminalPreferences.cursorStyle, disableStdin: readOnly, scrollback: 10000, // Defensive: do NOT register an OSC 52 (clipboard-write) handler. The // server-side neutralizer (U10) strips it; we add no client handling. - fontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', - fontSize: 13, + fontFamily: resolvedFontFamily, + fontSize: terminalPreferences.fontSize, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); @@ -316,22 +367,29 @@ export function SessionTerminal({ xtermRef.current = term; fitAddonRef.current = fitAddon as unknown as ITerminalAddon; - // WebGL renderer with context-loss fallback to the DOM renderer. - try { - const { WebglAddon } = await import("@xterm/addon-webgl"); - if (!disposed) { - const webgl = new WebglAddon(); - webgl.onContextLoss(() => { - try { - webgl.dispose(); - } catch { - /* fall back to DOM renderer */ - } - }); - term.loadAddon(webgl); + /* + FNXC:Terminal 2026-06-17-00:55: + The embedded session terminal follows the shared renderer preference, but mobile viewports are a hard WebGL skip floor to avoid glyph artifacts in WebKit. Renderer changes are init-only because swapping xterm render addons mid-session is unsafe; users get the new renderer on the next mount/session. + */ + const shouldLoadWebgl = terminalPreferences.renderer === "auto" && !isMobileViewport(); + if (shouldLoadWebgl) { + // WebGL renderer with context-loss fallback to the DOM renderer. + try { + const { WebglAddon } = await import("@xterm/addon-webgl"); + if (!disposed) { + const webgl = new WebglAddon(); + webgl.onContextLoss(() => { + try { + webgl.dispose(); + } catch { + /* fall back to DOM renderer */ + } + }); + term.loadAddon(webgl); + } + } catch { + /* WebGL unavailable — DOM renderer is the default fallback */ } - } catch { - /* WebGL unavailable — DOM renderer is the default fallback */ } try { diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 2d97e6ef8d..1e5ca798af 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -4,6 +4,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode"; // ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ────────── +const mockFitAddon = { fit: vi.fn() }; const mockTerm = { loadAddon: vi.fn(), open: vi.fn(), @@ -11,11 +12,12 @@ const mockTerm = { write: vi.fn((_data: string, cb?: () => void) => cb?.()), dispose: vi.fn(), unicode: { activeVersion: "6" }, + options: {} as Record, cols: 80, rows: 24, }; -vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal() { return mockTerm; }) })); -vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return { fit: vi.fn() }; }) })); +vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal(options) { mockTerm.options = { ...options }; return mockTerm; }) })); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return mockFitAddon; }) })); vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(function Unicode11Addon() { return {}; }) })); vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: vi.fn(function WebglAddon() { return { onContextLoss: vi.fn(), dispose: vi.fn() }; }), @@ -91,6 +93,7 @@ function stubScreen(width: number, height: number) { } import { SessionTerminal } from "../SessionTerminal"; +import { DEFAULT_TERMINAL_PREFERENCES, TERMINAL_PREFERENCES_KEY } from "../../utils/terminalPreferences"; /** Pull the parsed input frames a WS has sent. */ function inputFrames(ws: FakeWS): string[] { @@ -111,8 +114,14 @@ beforeEach(() => { FakeWS.instances = []; originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket; (globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS; + window.localStorage.clear(); + mockTerm.loadAddon.mockClear(); + mockTerm.open.mockClear(); mockTerm.onData.mockReset(); mockTerm.write.mockClear(); + mockTerm.dispose.mockClear(); + mockTerm.options = {}; + mockFitAddon.fit.mockClear(); apiMock.mockReset(); apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); installMatchMedia(true); // mobile by default @@ -298,6 +307,41 @@ describe("SessionTerminal (mobile)", () => { await renderMobile(); expect(mockTerm.onData).toHaveBeenCalled(); }); + + it("never loads WebGL on mobile even when renderer preference is auto", async () => { + const { WebglAddon } = await import("@xterm/addon-webgl"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "auto" }), + ); + + await renderMobile(); + + expect(WebglAddon).not.toHaveBeenCalled(); + }); + + it("keeps the accessory key bar intact while applying terminal preferences", async () => { + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontFamily: "fira-code", + cursorStyle: "underline", + }), + ); + + await renderMobile(); + + expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy(); + expect(screen.getByTestId("cli-key-ctrl")).toBeTruthy(); + expect(screen.getByTestId("cli-key-esc")).toBeTruthy(); + expect(screen.getByTestId("cli-key-tab")).toBeTruthy(); + expect(screen.getByTestId("cli-key-ctrl-c")).toBeTruthy(); + expect(screen.getByTestId("cli-key-arrow-up")).toBeTruthy(); + expect(screen.getByTestId("cli-key-arrow-down")).toBeTruthy(); + expect(screen.getByTestId("cli-key-arrow-left")).toBeTruthy(); + expect(screen.getByTestId("cli-key-arrow-right")).toBeTruthy(); + }); }); // ── Keyboard-open (fixed-footer) + pinch-zoom guard ────────────────────────── @@ -345,7 +389,12 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => { beforeEach(() => { FakeWS.instances = []; + window.localStorage.clear(); + mockTerm.loadAddon.mockClear(); + mockTerm.open.mockClear(); mockTerm.onData.mockReset(); + mockTerm.options = {}; + mockFitAddon.fit.mockClear(); apiMock.mockReset(); apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); installMatchMedia(true); diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index a58b7d4258..96a8806f58 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; // ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ────────── +const mockFitAddon = { fit: vi.fn() }; const mockTerm = { loadAddon: vi.fn(), open: vi.fn(), @@ -10,11 +11,12 @@ const mockTerm = { write: vi.fn((_data: string, cb?: () => void) => cb?.()), dispose: vi.fn(), unicode: { activeVersion: "6" }, + options: {} as Record, cols: 80, rows: 24, }; -vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal() { return mockTerm; }) })); -vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return { fit: vi.fn() }; }) })); +vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal(options) { mockTerm.options = { ...options }; return mockTerm; }) })); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return mockFitAddon; }) })); vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(function Unicode11Addon() { return {}; }) })); vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: vi.fn(function WebglAddon() { return { onContextLoss: vi.fn(), dispose: vi.fn() }; }), @@ -51,14 +53,24 @@ let originalWebSocket: typeof WebSocket | undefined; }; import { SessionTerminal } from "../SessionTerminal"; +import { + DEFAULT_TERMINAL_PREFERENCES, + TERMINAL_PREFERENCES_KEY, +} from "../../utils/terminalPreferences"; beforeEach(() => { FakeWS.instances = []; originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket; (globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS; + window.localStorage.clear(); + mockTerm.loadAddon.mockClear(); + mockTerm.open.mockClear(); mockTerm.onData.mockReset(); mockTerm.attachCustomKeyEventHandler.mockClear(); mockTerm.write.mockClear(); + mockTerm.dispose.mockClear(); + mockTerm.options = {}; + mockFitAddon.fit.mockClear(); apiMock.mockReset(); apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); }); @@ -97,7 +109,7 @@ describe("SessionTerminal", () => { expect(mockTerm.onData).not.toHaveBeenCalled(); }); - it("relies on native xterm paste with the system monospace font", async () => { + it("relies on native xterm paste while applying the default terminal font preference", async () => { const { Terminal } = await import("@xterm/xterm"); render(); @@ -105,12 +117,10 @@ describe("SessionTerminal", () => { await waitFor(() => expect(FakeWS.instances.length).toBe(1)); expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - fontFamily: expect.stringContaining("ui-monospace"), - }), - ); - expect(Terminal).toHaveBeenCalledWith( - expect.objectContaining({ - fontFamily: expect.not.stringContaining("Fusion Terminal Nerd Font Symbols"), + fontFamily: expect.stringContaining("Fusion Terminal Nerd Font Symbols"), + fontSize: DEFAULT_TERMINAL_PREFERENCES.fontSize, + cursorStyle: DEFAULT_TERMINAL_PREFERENCES.cursorStyle, + cursorBlink: DEFAULT_TERMINAL_PREFERENCES.cursorBlink, }), ); expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled(); @@ -126,6 +136,144 @@ describe("SessionTerminal", () => { ]); }); + it("applies validated terminal preferences at xterm init", async () => { + const { Terminal } = await import("@xterm/xterm"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ + fontFamily: "system-mono", + fontSize: 18, + cursorStyle: "underline", + cursorBlink: true, + renderer: "auto", + }), + ); + + render(); + + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + fontSize: 18, + cursorStyle: "underline", + cursorBlink: true, + }), + ); + }); + + it("falls back to safe default preferences for corrupt storage", async () => { + const { Terminal } = await import("@xterm/xterm"); + window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, "{not-json"); + + render(); + + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + fontFamily: expect.stringContaining("Fusion Terminal Nerd Font Symbols"), + fontSize: DEFAULT_TERMINAL_PREFERENCES.fontSize, + cursorStyle: DEFAULT_TERMINAL_PREFERENCES.cursorStyle, + cursorBlink: true, + }), + ); + }); + + it.each([ + { label: "read-only", props: { readOnly: true } }, + { label: "idle", props: { mode: "idle" as const } }, + { label: "ended", props: { mode: "ended" as const } }, + ])("keeps cursor blink disabled for $label sessions", async ({ props }) => { + const { Terminal } = await import("@xterm/xterm"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, cursorBlink: true }), + ); + + render(); + + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + cursorBlink: false, + }), + ); + }); + + it("skips WebGL on desktop when renderer preference is canvas", async () => { + const { WebglAddon } = await import("@xterm/addon-webgl"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "canvas" }), + ); + + render(); + + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + await waitFor(() => expect(mockTerm.open).toHaveBeenCalled()); + expect(WebglAddon).not.toHaveBeenCalled(); + }); + + it("loads WebGL on desktop when renderer preference is auto", async () => { + const { WebglAddon } = await import("@xterm/addon-webgl"); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "auto" }), + ); + + render(); + + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + await waitFor(() => expect(WebglAddon).toHaveBeenCalled()); + expect(mockTerm.loadAddon).toHaveBeenCalledWith( + expect.objectContaining({ onContextLoss: expect.any(Function) }), + ); + }); + + it("live-applies font and cursor preference changes from storage events", async () => { + render(); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + mockFitAddon.fit.mockClear(); + + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ + fontFamily: "jetbrains-mono", + fontSize: 20, + cursorStyle: "bar", + cursorBlink: false, + renderer: "canvas", + }), + ); + window.dispatchEvent(new StorageEvent("storage", { key: TERMINAL_PREFERENCES_KEY })); + + await waitFor(() => { + expect(mockTerm.options).toMatchObject({ + fontFamily: + '"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace', + fontSize: 20, + cursorStyle: "bar", + cursorBlink: false, + }); + }); + expect(mockFitAddon.fit).toHaveBeenCalled(); + }); + + it("ignores unrelated storage events when live-applying preferences", async () => { + render(); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + mockFitAddon.fit.mockClear(); + + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 22 }), + ); + window.dispatchEvent(new StorageEvent("storage", { key: "unrelated" })); + + expect(mockTerm.options.fontSize).not.toBe(22); + expect(mockFitAddon.fit).not.toHaveBeenCalled(); + }); + it("renders the Read-only badge when readOnly", async () => { render(); expect(await screen.findByText("Read-only")).toBeTruthy();