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
This commit is contained in:
gsxdsm
2026-06-17 01:15:20 -07:00
parent e923b5e84d
commit bd328f2282
4 changed files with 286 additions and 30 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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<string, unknown>,
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);

View File

@@ -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<string, unknown>,
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(<SessionTerminal sessionId="s1" />);
@@ -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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" {...props} />);
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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" />);
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(<SessionTerminal sessionId="s1" readOnly />);
expect(await screen.findByText("Read-only")).toBeTruthy();