diff --git a/.changeset/fn-7872-terminal-custom-shortcuts.md b/.changeset/fn-7872-terminal-custom-shortcuts.md new file mode 100644 index 0000000000..85173103c4 --- /dev/null +++ b/.changeset/fn-7872-terminal-custom-shortcuts.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 945ccdce6c..d150bb84e6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index f881055210..3aae4d477d 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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)); diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 4a91f5a4df..e9b7d84085 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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(() => readTerminalPreferences(), ); + const [customShortcutLabel, setCustomShortcutLabel] = useState(""); + const [customShortcutValue, setCustomShortcutValue] = useState(""); + const [editingCustomShortcutId, setEditingCustomShortcutId] = useState(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} ))} + {/** + * 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) => ( + + ))} )} @@ -3025,6 +3128,103 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG )} +
+
+
+

{t("terminal.customShortcutsTitle", "Custom shortcuts")}

+

+ {t( + "terminal.customShortcutsHelp", + "Use \\n for Enter, \\t for Tab, \\e or \\x1b for Esc, \\r for Return, and \\\\ for a literal backslash.", + )} +

+
+ + {terminalPreferences.customShortcuts.length}/{MAX_TERMINAL_CUSTOM_SHORTCUTS} + +
+ {terminalPreferences.customShortcuts.length === 0 ? ( +

+ {t("terminal.customShortcutsEmpty", "No custom shortcuts yet.")} +

+ ) : ( +
    + {terminalPreferences.customShortcuts.map((shortcut) => ( +
  • + + {shortcut.label} + {shortcut.value} + + + + + +
  • + ))} +
+ )} +
+ + +
+ {isEditingCustomShortcut && ( + + )} + +
+
+