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:
gsxdsm
2026-06-17 00:06:24 -07:00
parent 934baa9259
commit e923b5e84d
6 changed files with 690 additions and 63 deletions

View File

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

View File

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

View File

@@ -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}`}>

View File

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

View File

@@ -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");
});
});

View 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;
}