FN-7872: add custom terminal shortcut buttons to the Preferences panel

Lets users define, edit, and remove custom terminal shortcut buttons (label + injected key sequence) from the terminal Preferences panel, persisted client-side.

- Add a customShortcuts list to terminalPreferences (kb-terminal-preferences localStorage) with add/edit/remove management
- Add decodeTerminalShortcutSequence to decode \n, \t, \r, \e/\x1b, and \\ escapes for injected sequences
- Render custom shortcut buttons in TerminalModal's shortcut panel, injecting via the focus-preserving sendLiteralShortcut path
- Add management UI (add/edit/remove) for custom shortcuts in the terminal Preferences panel, styled in TerminalModal.css
- Extend TerminalModal and terminalPreferences test coverage for the new custom shortcut behavior
- Document custom terminal shortcuts in docs/dashboard-guide.md
- Add a minor changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7872-terminal-custom-shortcuts.md    |   7 +
 docs/dashboard-guide.md                            |   7 +-
 .../dashboard/app/components/TerminalModal.css     | 106 +++++++++++
 .../dashboard/app/components/TerminalModal.tsx     | 202 ++++++++++++++++++++-
 .../components/__tests__/TerminalModal.test.tsx    | 183 +++++++++++++++++++
 .../utils/__tests__/terminalPreferences.test.ts    |  68 +++++++
 .../dashboard/app/utils/terminalPreferences.ts     | 140 +++++++++++++-
 7 files changed, 708 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7872

Fusion-Task-Lineage: 9b2df0da-0eb7-4cec-a42b-767e23ff4c2c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 13:06:08 -07:00
parent ad3d26d365
commit b77e12351e
7 changed files with 708 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let users define custom terminal shortcut buttons (label + injected sequence) from the terminal Preferences panel.
category: feature
dev: Adds a customShortcuts list to the client-local terminalPreferences (kb-terminal-preferences localStorage), a decodeTerminalShortcutSequence escape decoder (\n/\t/\r/\e/\x1b/\\), custom shortcut buttons in TerminalModal's shortcut panel injecting via the focus-preserving sendLiteralShortcut path, and add/edit/remove management UI in the preferences panel. Client-only; no server schema.

View File

@@ -684,9 +684,10 @@ 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 exactly once into the active integrated terminal or live embedded CLI 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
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell
- 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
- The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, Up/Down/Left/Right arrow buttons, and any custom shortcut buttons you define for keyboard-less shell history, line editing, or frequent snippets
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations and custom snippets reliably emit bytes to the shell
- The Preferences panel customizes font family, font size, cursor style, cursor blink, renderer, and custom shortcut buttons; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
- Custom shortcuts have a short label and injected value. Use `\n` for Enter, `\t` for Tab, `\r` for Return, `\e` or `\x1b` for Esc, and `\\` for a literal backslash; unknown escapes are sent literally. Add, edit, or remove them from the terminal Preferences panel, and reset terminal preferences to clear them.
- 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 and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and Ctrl/Cmd+V sends clipboard text exactly once to the attach channel. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. 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

View File

@@ -1310,6 +1310,100 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
justify-self: start;
}
.terminal-shortcut-btn--custom {
border-color: var(--accent);
background: var(--surface-elevated);
}
.terminal-custom-shortcuts {
display: flex;
flex-direction: column;
gap: var(--space-sm);
grid-column: 1 / -1;
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.terminal-custom-shortcuts__header,
.terminal-custom-shortcuts__row,
.terminal-custom-shortcuts__form,
.terminal-custom-shortcuts__form-actions,
.terminal-custom-shortcuts__actions {
display: flex;
gap: var(--space-sm);
}
.terminal-custom-shortcuts__header {
align-items: flex-start;
justify-content: space-between;
}
.terminal-custom-shortcuts__header h3 {
margin: 0;
color: var(--text);
font-size: 0.95rem;
}
.terminal-custom-shortcuts__count,
.terminal-custom-shortcuts__empty {
color: var(--text-muted);
font-size: 0.8rem;
}
.terminal-custom-shortcuts__list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: 0;
list-style: none;
}
.terminal-custom-shortcuts__row {
align-items: center;
justify-content: space-between;
padding: var(--space-xs);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.terminal-custom-shortcuts__summary {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-2xs);
}
.terminal-custom-shortcuts__summary code {
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-custom-shortcuts__actions,
.terminal-custom-shortcuts__form-actions {
align-items: center;
flex: 0 0 auto;
}
.terminal-custom-shortcuts__form {
align-items: flex-end;
}
.terminal-custom-shortcuts__form .terminal-preference-field {
flex: 1 1 0;
}
.terminal-custom-shortcuts__submit {
white-space: nowrap;
}
.terminal-connection-status {
font-weight: 500;
white-space: nowrap;
@@ -1728,6 +1822,18 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
align-self: stretch;
}
.terminal-custom-shortcuts__header,
.terminal-custom-shortcuts__row,
.terminal-custom-shortcuts__form,
.terminal-custom-shortcuts__form-actions {
align-items: stretch;
flex-direction: column;
}
.terminal-custom-shortcuts__actions {
flex-wrap: wrap;
}
.terminal-font-size-btn {
min-width: calc(var(--space-xl) + var(--space-md));
min-height: calc(var(--space-xl) + var(--space-md));

View File

@@ -37,17 +37,24 @@ import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import { getPathBasename } from "../utils/pathDisplay";
import {
DEFAULT_TERMINAL_PREFERENCES,
MAX_TERMINAL_CUSTOM_SHORTCUTS,
MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH,
MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH,
MAX_TERMINAL_FONT_SIZE,
MIN_TERMINAL_FONT_SIZE,
TERMINAL_FONT_FAMILY_PRESETS,
clampTerminalFontSize,
createTerminalCustomShortcutId,
decodeTerminalShortcutSequence,
forceTerminalFontRemeasure,
normalizeTerminalCustomShortcuts,
readTerminalPreferences,
resolveTerminalFontFamily,
resolveTerminalGlyphFontFamily,
waitForTerminalFontMetrics,
withDomBasedTerminalCharacterMeasurement,
writeTerminalPreferences,
type TerminalCustomShortcut,
type TerminalPreferences,
type TerminalRenderer,
} from "../utils/terminalPreferences";
@@ -551,6 +558,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const [terminalPreferences, setTerminalPreferences] = useState<TerminalPreferences>(() =>
readTerminalPreferences(),
);
const [customShortcutLabel, setCustomShortcutLabel] = useState("");
const [customShortcutValue, setCustomShortcutValue] = useState("");
const [editingCustomShortcutId, setEditingCustomShortcutId] = useState<string | null>(null);
const fontSize = terminalPreferences.fontSize;
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
/*
@@ -1325,6 +1335,78 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
setTerminalPreferences((current) => writeTerminalPreferences({ ...current, ...patch }));
}, []);
const resetCustomShortcutForm = useCallback(() => {
setCustomShortcutLabel("");
setCustomShortcutValue("");
setEditingCustomShortcutId(null);
}, []);
const persistCustomShortcuts = useCallback(
(shortcuts: TerminalCustomShortcut[]) => {
updateTerminalPreferences({
customShortcuts: normalizeTerminalCustomShortcuts(shortcuts),
});
},
[updateTerminalPreferences],
);
const startEditingCustomShortcut = useCallback((shortcut: TerminalCustomShortcut) => {
setCustomShortcutLabel(shortcut.label);
setCustomShortcutValue(shortcut.value);
setEditingCustomShortcutId(shortcut.id);
}, []);
const removeCustomShortcut = useCallback(
(shortcutId: string) => {
persistCustomShortcuts(
terminalPreferences.customShortcuts.filter((shortcut) => shortcut.id !== shortcutId),
);
if (editingCustomShortcutId === shortcutId) {
resetCustomShortcutForm();
}
},
[editingCustomShortcutId, persistCustomShortcuts, resetCustomShortcutForm, terminalPreferences.customShortcuts],
);
const trimmedCustomShortcutLabel = customShortcutLabel.trim();
const trimmedCustomShortcutValue = customShortcutValue.trim();
const isEditingCustomShortcut = editingCustomShortcutId !== null;
const customShortcutLimitReached =
terminalPreferences.customShortcuts.length >= MAX_TERMINAL_CUSTOM_SHORTCUTS;
const canSubmitCustomShortcut =
trimmedCustomShortcutLabel !== "" &&
trimmedCustomShortcutValue !== "" &&
(isEditingCustomShortcut || !customShortcutLimitReached);
const submitCustomShortcut = useCallback(() => {
if (!canSubmitCustomShortcut) {
return;
}
const nextShortcut: TerminalCustomShortcut = {
id: editingCustomShortcutId ?? createTerminalCustomShortcutId(),
label: trimmedCustomShortcutLabel,
value: trimmedCustomShortcutValue,
};
const nextShortcuts = isEditingCustomShortcut
? terminalPreferences.customShortcuts.map((shortcut) =>
shortcut.id === editingCustomShortcutId ? nextShortcut : shortcut,
)
: [...terminalPreferences.customShortcuts, nextShortcut];
persistCustomShortcuts(nextShortcuts);
resetCustomShortcutForm();
}, [
canSubmitCustomShortcut,
editingCustomShortcutId,
isEditingCustomShortcut,
persistCustomShortcuts,
resetCustomShortcutForm,
terminalPreferences.customShortcuts,
trimmedCustomShortcutLabel,
trimmedCustomShortcutValue,
]);
const setFontSize = useCallback(
(value: number | ((current: number) => number)) => {
setTerminalPreferences((current) => {
@@ -1341,7 +1423,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const resetTerminalPreferences = useCallback(() => {
setTerminalPreferences(writeTerminalPreferences(DEFAULT_TERMINAL_PREFERENCES));
}, []);
resetCustomShortcutForm();
}, [resetCustomShortcutForm]);
const refitTerminal = useCallback(() => {
const terminal = xtermRef.current;
@@ -2940,6 +3023,26 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{shortcut.label}
</button>
))}
{/**
* FNXC:Terminal 2026-07-12-00:00:
* FN-7872 user-defined shortcuts must inject their decoded value through the same sendLiteralShortcut path as built-in literal shortcuts. Keep the pointer/mouse/touch focus guards so the FN-6697/FN-6737 xterm-refocus invariant holds for custom buttons on desktop and touch surfaces.
*/}
{terminalPreferences.customShortcuts.map((shortcut) => (
<button
key={shortcut.id}
type="button"
className="terminal-shortcut-btn terminal-shortcut-btn--custom"
data-testid={`terminal-custom-shortcut-${shortcut.id}`}
title={shortcut.label}
aria-label={shortcut.label}
onPointerDown={preserveShortcutFocus}
onMouseDown={preserveShortcutFocus}
onTouchStart={preserveShortcutFocus}
onClick={() => sendLiteralShortcut(decodeTerminalShortcutSequence(shortcut.value))}
>
{shortcut.label}
</button>
))}
</div>
)}
@@ -3025,6 +3128,103 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</span>
)}
</label>
<section className="terminal-custom-shortcuts" data-testid="terminal-custom-shortcuts">
<div className="terminal-custom-shortcuts__header">
<div>
<h3>{t("terminal.customShortcutsTitle", "Custom shortcuts")}</h3>
<p className="terminal-preference-note">
{t(
"terminal.customShortcutsHelp",
"Use \\n for Enter, \\t for Tab, \\e or \\x1b for Esc, \\r for Return, and \\\\ for a literal backslash.",
)}
</p>
</div>
<span className="terminal-custom-shortcuts__count">
{terminalPreferences.customShortcuts.length}/{MAX_TERMINAL_CUSTOM_SHORTCUTS}
</span>
</div>
{terminalPreferences.customShortcuts.length === 0 ? (
<p className="terminal-custom-shortcuts__empty" data-testid="terminal-custom-shortcuts-empty">
{t("terminal.customShortcutsEmpty", "No custom shortcuts yet.")}
</p>
) : (
<ul className="terminal-custom-shortcuts__list" aria-label={t("terminal.customShortcutsList", "Custom terminal shortcuts")}>
{terminalPreferences.customShortcuts.map((shortcut) => (
<li key={shortcut.id} className="terminal-custom-shortcuts__row">
<span className="terminal-custom-shortcuts__summary">
<strong>{shortcut.label}</strong>
<code>{shortcut.value}</code>
</span>
<span className="terminal-custom-shortcuts__actions">
<button
type="button"
className="btn btn-secondary"
data-testid={`terminal-custom-shortcut-edit-${shortcut.id}`}
onClick={() => startEditingCustomShortcut(shortcut)}
>
{t("common.edit", "Edit")}
</button>
<button
type="button"
className="btn btn-secondary"
data-testid={`terminal-custom-shortcut-remove-${shortcut.id}`}
onClick={() => removeCustomShortcut(shortcut.id)}
>
{t("common.remove", "Remove")}
</button>
</span>
</li>
))}
</ul>
)}
<div className="terminal-custom-shortcuts__form">
<label className="terminal-preference-field">
<span>{t("terminal.customShortcutLabel", "Button label")}</span>
<input
className="input terminal-preference-control"
data-testid="terminal-custom-shortcut-label-input"
type="text"
maxLength={MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH}
value={customShortcutLabel}
onChange={(event) => setCustomShortcutLabel(event.target.value)}
/>
</label>
<label className="terminal-preference-field">
<span>{t("terminal.customShortcutValue", "Injected value")}</span>
<input
className="input terminal-preference-control"
data-testid="terminal-custom-shortcut-value-input"
type="text"
maxLength={MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH}
value={customShortcutValue}
onChange={(event) => setCustomShortcutValue(event.target.value)}
/>
</label>
<div className="terminal-custom-shortcuts__form-actions">
{isEditingCustomShortcut && (
<button
type="button"
className="btn btn-secondary"
data-testid="terminal-custom-shortcut-cancel"
onClick={resetCustomShortcutForm}
>
{t("common.cancel", "Cancel")}
</button>
)}
<button
type="button"
className="btn terminal-custom-shortcuts__submit"
data-testid="terminal-custom-shortcut-add"
disabled={!canSubmitCustomShortcut}
onClick={submitCustomShortcut}
>
{isEditingCustomShortcut
? t("terminal.customShortcutSave", "Save shortcut")
: t("terminal.customShortcutAdd", "Add shortcut")}
</button>
</div>
</div>
</section>
<button
type="button"
className="btn terminal-preferences-reset"

View File

@@ -9,6 +9,7 @@ import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar, evaluate
import {
DEFAULT_TERMINAL_PREFERENCES,
LEGACY_TERMINAL_FONT_SIZE_KEY,
MAX_TERMINAL_CUSTOM_SHORTCUTS,
TERMINAL_PREFERENCES_KEY,
TERMINAL_SYMBOLS_FONT_FAMILY,
XTERM_FONT_FAMILY,
@@ -2070,6 +2071,77 @@ describe("TerminalModal", () => {
expect(screen.getByTestId("terminal-modifier-ctrl").getAttribute("aria-pressed")).toBe("false");
});
it("renders persisted custom shortcuts and injects decoded values without stealing focus", async () => {
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({
...DEFAULT_TERMINAL_PREFERENCES,
customShortcuts: [{ id: "cs-status", label: "Status", value: "git status\\n" }],
}),
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
const terminalDiv = screen.getByTestId("terminal-xterm");
const helperTextarea = document.createElement("textarea");
helperTextarea.className = "xterm-helper-textarea";
const focusSpy = vi.spyOn(helperTextarea, "focus");
terminalDiv.appendChild(helperTextarea);
helperTextarea.focus();
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
const customButton = screen.getByTestId("terminal-custom-shortcut-cs-status");
expect(customButton).toHaveClass("terminal-shortcut-btn--custom");
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
customButton.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(helperTextarea);
mockSendInput.mockClear();
mockTerminalInstance.focus.mockClear();
focusSpy.mockClear();
fireEvent.click(customButton);
expect(mockSendInput).toHaveBeenCalledWith("git status\n");
expect(mockTerminalInstance.focus).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
});
it("renders custom shortcut controls on mobile without empty shells", async () => {
const previousInnerWidth = window.innerWidth;
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({
...DEFAULT_TERMINAL_PREFERENCES,
customShortcuts: [{ id: "cs-mobile", label: "Clear", value: "clear\\n" }],
}),
);
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--mobile");
expect(screen.getByTestId("terminal-custom-shortcut-cs-mobile")).toHaveTextContent("Clear");
expect(screen.getByTestId("terminal-custom-shortcuts")).toBeTruthy();
expect(screen.queryByTestId("terminal-custom-shortcuts-empty")).toBeNull();
} finally {
Object.defineProperty(window, "innerWidth", {
value: previousInnerWidth,
writable: true,
configurable: true,
});
}
});
it("renders shortcut controls on mobile viewport", async () => {
const previousInnerWidth = window.innerWidth;
const previousOntouchstart = window.ontouchstart;
@@ -2326,6 +2398,117 @@ describe("TerminalModal", () => {
expect(screen.getByTestId("terminal-renderer-reopen-note")).toBeTruthy();
});
});
it("adds and edits custom shortcuts through the shared preferences record", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
expect(addButton).toHaveProperty("disabled", true);
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
target: { value: "Status" },
});
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
target: { value: "git status\\n" },
});
expect(addButton).toHaveProperty("disabled", false);
fireEvent.click(addButton);
const persisted = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
expect(persisted.customShortcuts).toEqual([
expect.objectContaining({ label: "Status", value: "git status\\n" }),
]);
const shortcutId = persisted.customShortcuts[0].id;
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Status");
fireEvent.click(screen.getByTestId(`terminal-custom-shortcut-edit-${shortcutId}`));
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
target: { value: "Clear" },
});
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
target: { value: "clear\\n" },
});
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
const edited = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
expect(edited.customShortcuts).toEqual([
{ id: shortcutId, label: "Clear", value: "clear\\n" },
]);
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Clear");
});
it("removes custom shortcuts and reset to defaults clears them without leftover shells", async () => {
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({
...DEFAULT_TERMINAL_PREFERENCES,
customShortcuts: [{ id: "cs-clear", label: "Clear", value: "clear\\n" }],
}),
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
expect(screen.getByTestId("terminal-custom-shortcut-cs-clear")).toBeTruthy();
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-remove-cs-clear"));
expect(screen.queryByTestId("terminal-custom-shortcut-cs-clear")).toBeNull();
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
expect(
JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null").customShortcuts,
).toEqual([]);
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
target: { value: "Again" },
});
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
target: { value: "echo again\\n" },
});
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(1);
fireEvent.click(screen.getByTestId("terminal-preferences-reset"));
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
expect(JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(
DEFAULT_TERMINAL_PREFERENCES,
);
});
it("disables custom shortcut add when inputs are empty or the cap is reached", async () => {
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({
...DEFAULT_TERMINAL_PREFERENCES,
customShortcuts: Array.from({ length: MAX_TERMINAL_CUSTOM_SHORTCUTS }, (_, index) => ({
id: `cs-${index}`,
label: `S${index}`,
value: `echo ${index}`,
})),
}),
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
expect(addButton).toHaveProperty("disabled", true);
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
target: { value: "Overflow" },
});
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
target: { value: "echo overflow\\n" },
});
expect(addButton).toHaveProperty("disabled", true);
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-edit-cs-0"));
expect(addButton).toHaveProperty("disabled", false);
});
});
it("xterm container is rendered (visible under loading overlay) while loading", async () => {

View File

@@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_TERMINAL_PREFERENCES,
LEGACY_TERMINAL_FONT_SIZE_KEY,
MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH,
MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH,
MAX_TERMINAL_CUSTOM_SHORTCUTS,
TERMINAL_PREFERENCES_KEY,
XTERM_FONT_FAMILY,
decodeTerminalShortcutSequence,
forceTerminalFontRemeasure,
normalizeTerminalCustomShortcuts,
readTerminalPreferences,
waitForTerminalFontMetrics,
writeTerminalPreferences,
@@ -81,6 +86,7 @@ describe("terminalPreferences", () => {
});
expect(written).toEqual({
...DEFAULT_TERMINAL_PREFERENCES,
fontFamily: "system-mono",
fontSize: 22,
cursorStyle: "underline",
@@ -91,6 +97,68 @@ describe("terminalPreferences", () => {
expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22");
});
it("normalizes missing, corrupt, and non-array custom shortcuts to an empty list", () => {
expect(readTerminalPreferences().customShortcuts).toEqual([]);
localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, customShortcuts: "bad" }),
);
expect(readTerminalPreferences().customShortcuts).toEqual([]);
expect(normalizeTerminalCustomShortcuts(null)).toEqual([]);
});
it("drops invalid custom shortcuts and caps labels, values, ids, and list length", () => {
const longLabel = ` ${"L".repeat(MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH + 5)} `;
const longValue = ` ${"v".repeat(MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH + 5)} `;
const normalized = normalizeTerminalCustomShortcuts([
null,
{ id: "keep", label: " Status ", value: " git status\\n " },
{ id: "empty-label", label: " ", value: "echo nope" },
{ id: "empty-value", label: "Nope", value: " " },
{ id: "keep", label: "Duplicate", value: "pwd\\n" },
{ label: longLabel, value: longValue },
...Array.from({ length: MAX_TERMINAL_CUSTOM_SHORTCUTS + 5 }, (_, index) => ({
id: `extra-${index}`,
label: `E${index}`,
value: `echo ${index}`,
})),
]);
expect(normalized).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUTS);
expect(normalized[0]).toEqual({ id: "keep", label: "Status", value: "git status\\n" });
expect(normalized[1]?.id).not.toBe("keep");
expect(new Set(normalized.map((shortcut) => shortcut.id)).size).toBe(normalized.length);
expect(normalized[2]?.label).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH);
expect(normalized[2]?.value).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH);
});
it("round-trips valid custom shortcuts through the existing preferences record", () => {
const written = writeTerminalPreferences({
customShortcuts: [
{ id: "cs-status", label: "Status", value: "git status\\n" },
{ id: "cs-clear", label: "Clear", value: "clear\\n" },
],
});
expect(written.customShortcuts).toEqual([
{ id: "cs-status", label: "Status", value: "git status\\n" },
{ id: "cs-clear", label: "Clear", value: "clear\\n" },
]);
expect(readTerminalPreferences().customShortcuts).toEqual(written.customShortcuts);
expect(JSON.parse(localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(written);
});
it("decodes terminal shortcut escape sequences and preserves unknown escapes", () => {
expect(decodeTerminalShortcutSequence("git status\\n")).toBe("git status\n");
expect(decodeTerminalShortcutSequence("col1\\tcol2\\r")).toBe("col1\tcol2\r");
expect(decodeTerminalShortcutSequence("esc=\\e alt=\\x1b")).toBe("esc=\x1b alt=\x1b");
expect(decodeTerminalShortcutSequence("literal=\\\\ unknown=\\q tail=\\")).toBe(
"literal=\\ unknown=\\q tail=\\",
);
});
it("keeps terminal font metrics wait best-effort when iOS rejects the full stack shorthand", async () => {
let readyAwaited = false;
const load = vi.fn((font: string) => {

View File

@@ -3,6 +3,9 @@ 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 MAX_TERMINAL_CUSTOM_SHORTCUTS = 24;
export const MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH = 24;
export const MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH = 256;
export const TERMINAL_SYMBOLS_FONT_FAMILY = '"Fusion Terminal Nerd Font Symbols"';
@@ -40,17 +43,27 @@ export type TerminalFontFamily = (typeof TERMINAL_FONT_FAMILY_PRESETS)[number]["
export type TerminalCursorStyle = "block" | "underline" | "bar";
export type TerminalRenderer = "auto" | "canvas";
export interface TerminalCustomShortcut {
id: string;
label: string;
value: string;
}
export interface TerminalPreferences {
fontFamily: TerminalFontFamily;
fontSize: number;
cursorStyle: TerminalCursorStyle;
cursorBlink: boolean;
renderer: TerminalRenderer;
customShortcuts: TerminalCustomShortcut[];
}
/*
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.
Terminal preferences are intentionally client-local: users can customize font, cursor, renderer, and custom shortcut buttons without introducing server settings schema. Reads must tolerate unavailable storage, corrupt JSON, unknown enum values, legacy font-size data, and malformed shortcut lists so opening the terminal never throws and always falls back to safe defaults.
FNXC:Terminal 2026-07-12-00:00:
FN-7872 custom shortcuts remain client-local in kb-terminal-preferences, are count/length-capped, and are defensively normalized before use so corrupt localStorage cannot break terminal startup. decodeTerminalShortcutSequence is the single injection-decoding boundary for user-authored shortcut sequences before TerminalModal sends bytes to the PTY.
*/
export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
fontFamily: "nerd-font",
@@ -58,6 +71,7 @@ export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
cursorStyle: "block",
cursorBlink: true,
renderer: "auto",
customShortcuts: [],
};
export function clampTerminalFontSize(value: number): number {
@@ -313,6 +327,129 @@ function isTerminalRenderer(value: unknown): value is TerminalRenderer {
return value === "auto" || value === "canvas";
}
let terminalCustomShortcutIdCounter = 0;
export function createTerminalCustomShortcutId(): string {
const maybeCrypto = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (typeof maybeCrypto?.randomUUID === "function") {
return `cs-${maybeCrypto.randomUUID()}`;
}
terminalCustomShortcutIdCounter += 1;
return `cs-${terminalCustomShortcutIdCounter.toString(36)}`;
}
function capTerminalShortcutText(value: string, maxLength: number): string {
return value.trim().slice(0, maxLength);
}
export function normalizeTerminalCustomShortcuts(
value: unknown,
): TerminalCustomShortcut[] {
if (!Array.isArray(value)) {
return [];
}
const normalized: TerminalCustomShortcut[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (normalized.length >= MAX_TERMINAL_CUSTOM_SHORTCUTS) {
break;
}
if (!isObject(entry)) {
continue;
}
const label =
typeof entry.label === "string"
? capTerminalShortcutText(entry.label, MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH)
: "";
const shortcutValue =
typeof entry.value === "string"
? capTerminalShortcutText(entry.value, MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH)
: "";
if (!label || !shortcutValue) {
continue;
}
const rawId = typeof entry.id === "string" ? entry.id.trim() : "";
let id = rawId && !seenIds.has(rawId) ? rawId : createTerminalCustomShortcutId();
if (seenIds.has(id)) {
const idBase = id;
let suffix = 2;
while (seenIds.has(`${idBase}-${suffix}`)) {
suffix += 1;
}
id = `${idBase}-${suffix}`;
}
seenIds.add(id);
normalized.push({ id, label, value: shortcutValue });
}
return normalized;
}
export function decodeTerminalShortcutSequence(value: string): string {
let decoded = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== "\\") {
decoded += character;
continue;
}
const next = value[index + 1];
if (next === undefined) {
decoded += character;
continue;
}
switch (next) {
case "n":
decoded += "\n";
index += 1;
break;
case "t":
decoded += "\t";
index += 1;
break;
case "r":
decoded += "\r";
index += 1;
break;
case "e":
decoded += "\x1b";
index += 1;
break;
case "\\":
decoded += "\\";
index += 1;
break;
case "x": {
const hexEscape = value.slice(index + 1, index + 4).toLowerCase();
if (hexEscape === "x1b") {
decoded += "\x1b";
index += 3;
break;
}
decoded += `\\${next}`;
index += 1;
break;
}
default:
decoded += `\\${next}`;
index += 1;
break;
}
}
return decoded;
}
function readLegacyFontSize(): number | undefined {
if (typeof window === "undefined") {
return undefined;
@@ -362,6 +499,7 @@ function normalizeTerminalPreferences(value: unknown): TerminalPreferences {
renderer: isTerminalRenderer(source.renderer)
? source.renderer
: DEFAULT_TERMINAL_PREFERENCES.renderer,
customShortcuts: normalizeTerminalCustomShortcuts(source.customShortcuts),
};
}