FN-6527: add terminal shortcuts and preferences
Adds persistent terminal customization and keyboard-less navigation controls to the dashboard terminal. - Add arrow shortcut buttons that send ANSI cursor sequences alongside existing Ctrl/Alt helpers. - Add localStorage-backed terminal preferences for font family, size, cursor style, blink, and renderer with legacy font-size migration. - Apply font and cursor preferences live, document renderer behavior, and cover preference parsing and modal controls with tests. Files changed: docs/dashboard-guide.md | 3 + .../dashboard/app/components/TerminalModal.css | 57 ++++- .../dashboard/app/components/TerminalModal.tsx | 271 ++++++++++++++++----- .../components/__tests__/TerminalModal.test.tsx | 135 +++++++++- .../utils/__tests__/terminalPreferences.test.ts | 90 +++++++ .../dashboard/app/utils/terminalPreferences.ts | 197 +++++++++++++++ 6 files changed, 690 insertions(+), 63 deletions(-) Fusion-Task-Id: FN-6527 Fusion-Task-Lineage: bc863486-9d2c-41d5-84a4-7b697e81a298
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [fontSize, setFontSize] = useState<number>(() => readInitialTerminalFontSize());
|
||||
const [terminalPreferences, setTerminalPreferences] = useState<TerminalPreferences>(() =>
|
||||
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 | "ctrl" | "alt">(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<TerminalRenderer>(terminalPreferences.renderer);
|
||||
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
|
||||
const pendingFitRef = useRef<number | null>(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<TerminalPreferences>) => {
|
||||
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
|
||||
<Keyboard size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.shortcuts", "Shortcuts")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-clear-btn terminal-clear-btn--shortcut"
|
||||
onClick={() => setShowPreferences((current) => !current)}
|
||||
data-testid="terminal-preferences-toggle"
|
||||
title={t("terminal.preferences", "Preferences")}
|
||||
aria-pressed={showPreferences}
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-close"
|
||||
onClick={onClose}
|
||||
@@ -1515,6 +1557,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
Tab
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:Terminal 2026-06-16-23:38:
|
||||
Touch users need literal ANSI arrow sequences for shell history and cursor movement. These shortcuts bypass sticky Ctrl/Alt modifiers so mobile navigation matches physical keyboard arrow keys exactly.
|
||||
*/}
|
||||
<div className="terminal-shortcut-arrow-row" aria-label="Terminal arrow keys">
|
||||
{ARROW_SHORTCUT_KEYS.map((arrow) => (
|
||||
<button
|
||||
key={arrow.testId}
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
data-testid={arrow.testId}
|
||||
aria-label={arrow.ariaLabel}
|
||||
onClick={() => sendLiteralShortcut(arrow.sequence)}
|
||||
>
|
||||
{arrow.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{SHORTCUT_KEYS.map((shortcut) => (
|
||||
<button
|
||||
key={shortcut.label}
|
||||
@@ -1529,6 +1589,99 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreferences && (
|
||||
<div className="terminal-preferences-panel" data-testid="terminal-preferences-panel">
|
||||
<label className="terminal-preference-field">
|
||||
<span>{t("terminal.preferenceFontFamily", "Font family")}</span>
|
||||
<select
|
||||
className="input terminal-preference-control"
|
||||
data-testid="terminal-preference-font-family"
|
||||
value={terminalPreferences.fontFamily}
|
||||
onChange={(event) =>
|
||||
updateTerminalPreferences({
|
||||
fontFamily: event.target.value as TerminalPreferences["fontFamily"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{TERMINAL_FONT_FAMILY_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="terminal-preference-field">
|
||||
<span>{t("terminal.preferenceFontSize", "Font size")}</span>
|
||||
<input
|
||||
className="input terminal-preference-control"
|
||||
data-testid="terminal-preference-font-size"
|
||||
type="number"
|
||||
min={MIN_TERMINAL_FONT_SIZE}
|
||||
max={MAX_TERMINAL_FONT_SIZE}
|
||||
value={terminalPreferences.fontSize}
|
||||
onChange={(event) => handlePreferenceFontSizeChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="terminal-preference-field">
|
||||
<span>{t("terminal.preferenceCursorStyle", "Cursor style")}</span>
|
||||
<select
|
||||
className="input terminal-preference-control"
|
||||
data-testid="terminal-preference-cursor-style"
|
||||
value={terminalPreferences.cursorStyle}
|
||||
onChange={(event) =>
|
||||
updateTerminalPreferences({
|
||||
cursorStyle: event.target.value as TerminalPreferences["cursorStyle"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="block">{t("terminal.cursorBlock", "Block")}</option>
|
||||
<option value="underline">{t("terminal.cursorUnderline", "Underline")}</option>
|
||||
<option value="bar">{t("terminal.cursorBar", "Bar")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="terminal-preference-field terminal-preference-field--checkbox">
|
||||
<input
|
||||
data-testid="terminal-preference-cursor-blink"
|
||||
type="checkbox"
|
||||
checked={terminalPreferences.cursorBlink}
|
||||
onChange={(event) =>
|
||||
updateTerminalPreferences({ cursorBlink: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>{t("terminal.preferenceCursorBlink", "Blink cursor")}</span>
|
||||
</label>
|
||||
<label className="terminal-preference-field">
|
||||
<span>{t("terminal.preferenceRenderer", "Renderer")}</span>
|
||||
<select
|
||||
className="input terminal-preference-control"
|
||||
data-testid="terminal-preference-renderer"
|
||||
value={terminalPreferences.renderer}
|
||||
onChange={(event) =>
|
||||
updateTerminalPreferences({
|
||||
renderer: event.target.value as TerminalPreferences["renderer"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="auto">{t("terminal.rendererAuto", "Auto (WebGL on desktop)")}</option>
|
||||
<option value="canvas">{t("terminal.rendererCanvas", "Canvas/DOM")}</option>
|
||||
</select>
|
||||
{xtermReady && terminalPreferences.renderer !== initializedRendererRef.current && (
|
||||
<span className="terminal-preference-note" data-testid="terminal-renderer-reopen-note">
|
||||
{t("terminal.rendererReopenNote", "Reopen the terminal to apply renderer changes.")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn terminal-preferences-reset"
|
||||
data-testid="terminal-preferences-reset"
|
||||
onClick={resetTerminalPreferences}
|
||||
>
|
||||
{t("terminal.resetPreferences", "Reset to defaults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status bar */}
|
||||
<div className="terminal-status-bar" data-testid="terminal-status-bar">
|
||||
<span className={`terminal-connection-status ${connectionStatus}`}>
|
||||
|
||||
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
197
packages/dashboard/app/utils/terminalPreferences.ts
Normal file
197
packages/dashboard/app/utils/terminalPreferences.ts
Normal file
@@ -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<string, unknown> {
|
||||
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>,
|
||||
): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user