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:
7
.changeset/fn-7872-terminal-custom-shortcuts.md
Normal file
7
.changeset/fn-7872-terminal-custom-shortcuts.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user