FN-7876: add custom terminal shortcut buttons to SessionTerminal mobile key bar

Extends the embedded Task Detail SessionTerminal to surface the shared, user-defined terminal shortcuts (from FN-7872's kb-terminal-preferences localStorage) as tappable buttons in its mobile accessory key bar.

- Read customShortcuts via the shared readTerminalPreferences() store on mount and refresh live on the storage event
- Render each custom shortcut as a mobile-only accessory-bar button that injects decodeTerminalShortcutSequence(value) through the focus-preserving keepFocus + sendInput path, clearing sticky Ctrl like the built-in ^C key
- Suppress the buttons for read-only/replay/idle/ended sessions via the existing canAcceptInput gate; desktop embedded terminals get no key bar
- Add .cli-terminal-key--custom styling for the new buttons
- Update docs/dashboard-guide.md to describe the mobile custom-shortcut key bar behavior
- Add a minor changeset for @runfusion/fusion documenting the feature
- Add SessionTerminal.mobile.test.tsx coverage for rendering, injection, live updates, and read-only suppression

Files changed:
 .changeset/fn-7876-session-terminal-custom-shortcuts.md                     |  7 ++
 docs/dashboard-guide.md                                                     |  2 +-
 packages/dashboard/app/components/SessionTerminal.css                      |  5 ++
 packages/dashboard/app/components/SessionTerminal.tsx                      | 31 +++++++
 packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx | 94 ++++++++++++++++++++++
 5 files changed, 138 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7876
Fusion-Task-Lineage: 61d62ad2-7357-4eb4-b542-6e17deea1e5e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 13:22:05 -07:00
parent a4dde88ff6
commit ee1d978984
5 changed files with 138 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Show user-defined custom terminal shortcuts in the embedded Task Detail terminal's mobile key bar.
category: feature
dev: SessionTerminal now reads the shared terminalPreferences.customShortcuts (FN-7872, kb-terminal-preferences localStorage) and renders each as a mobile accessory-bar button that injects decodeTerminalShortcutSequence(value) via the focus-preserving keepFocus + sendInput path, clearing sticky Ctrl; buttons update live on the storage event and are suppressed in read-only/replay sessions. Mobile-only; no new store; TerminalModal and the preferences helper are unchanged.

View File

@@ -689,7 +689,7 @@ Features:
- 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.
- 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. On mobile, the embedded terminal's accessory key bar also shows the same custom shortcuts defined in the terminal Preferences panel as tappable buttons that inject the decoded value; desktop embedded terminals have no key bar. 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
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)

View File

@@ -265,6 +265,11 @@ Recurrence #5 requires the attach terminal to mirror TerminalModal: xterm receiv
border-color: var(--color-warning);
}
.cli-terminal-key--custom {
color: var(--accent-text);
border-color: var(--accent);
}
.cli-session-terminal__input-row {
display: flex;
gap: var(--space-xs);

View File

@@ -10,12 +10,14 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import {
TERMINAL_PREFERENCES_KEY,
decodeTerminalShortcutSequence,
forceTerminalFontRemeasure,
readTerminalPreferences,
resolveTerminalFontFamily,
resolveTerminalGlyphFontFamily,
waitForTerminalFontMetrics,
withDomBasedTerminalCharacterMeasurement,
type TerminalCustomShortcut,
} from "../utils/terminalPreferences";
/**
@@ -207,6 +209,9 @@ export function SessionTerminal({
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
const [ctrlSticky, setCtrlSticky] = useState(false);
const [customShortcuts, setCustomShortcuts] = useState<TerminalCustomShortcut[]>(() =>
readTerminalPreferences().customShortcuts,
);
const [ticketReadOnly, setTicketReadOnly] = useState<boolean | null>(null);
const effectiveReadOnly = readOnly || ticketReadOnly === true;
const canAcceptInput = !readOnly && ticketReadOnly === false && mode === "live";
@@ -365,6 +370,9 @@ export function SessionTerminal({
/*
FNXC:Terminal 2026-06-17-01:05:
Font and cursor preferences live-apply through the shared storage key so SessionTerminal follows changes made in another terminal surface without remounting. Renderer remains excluded from this handler because renderer addon teardown/re-attach only happens safely during the next session init.
FNXC:Terminal 2026-07-12-13:20:
SessionTerminal custom shortcuts read FN-7872's shared client-local terminalPreferences store rather than a second embedded-terminal store. Always refresh through readTerminalPreferences() on storage events so corrupt, missing, or non-array customShortcuts normalize before React renders the mobile key bar.
*/
useEffect(() => {
if (typeof window === "undefined") {
@@ -376,6 +384,7 @@ export function SessionTerminal({
return;
}
applyLiveTerminalPreferences();
setCustomShortcuts(readTerminalPreferences().customShortcuts);
};
window.addEventListener("storage", onStorage);
@@ -951,6 +960,28 @@ export function SessionTerminal({
>
→
</button>
{/*
FNXC:Terminal 2026-07-12-13:26:
User-defined shortcut buttons must mirror built-in mobile bar keys: prevent pointer/mouse blur, clear sticky Ctrl like the dedicated ^C key, and send only decodeTerminalShortcutSequence(value) through sendInput. The surrounding canAcceptInput gate suppresses rendering and injection for read-only tickets, idle, ended, and replay sessions.
*/}
{customShortcuts.map((shortcut) => (
<button
key={shortcut.id}
type="button"
className="cli-terminal-key cli-terminal-key--custom"
data-testid={`cli-terminal-custom-shortcut-${shortcut.id}`}
title={shortcut.label}
aria-label={shortcut.label}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => {
setCtrlSticky(false);
sendInput(decodeTerminalShortcutSequence(shortcut.value));
}}
>
{shortcut.label}
</button>
))}
</div>
<form
className="cli-session-terminal__input-row"

View File

@@ -125,6 +125,15 @@ function expectMeasurementSafeFontStack(stack: string): void {
expect(families).not.toContain(TERMINAL_SYMBOLS_FONT_FAMILY);
}
function seedCustomShortcuts(
customShortcuts: Array<{ id: string; label: string; value: string }>,
): void {
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, customShortcuts }),
);
}
/** Pull the parsed input frames a WS has sent. */
function inputFrames(ws: FakeWS): string[] {
return ws.sent
@@ -373,6 +382,91 @@ describe("SessionTerminal (mobile)", () => {
expect(pdEvent.defaultPrevented).toBe(true);
});
describe("custom shortcuts", () => {
it("renders seeded shortcut buttons and injects decoded values", async () => {
seedCustomShortcuts([
{
id: "decode-all",
label: "Decode",
value: "git status\\nnext\\targ\\e\\x1b\\rslash\\\\unknown\\q",
},
]);
const { ws } = await renderMobile();
const button = screen.getByTestId("cli-terminal-custom-shortcut-decode-all");
expect(button.textContent).toBe("Decode");
fireEvent.click(button);
expect(inputFrames(ws)).toEqual([
"git status\nnext\targ\x1b\x1b\rslash\\unknown\\q",
]);
});
it("prevents pointerdown blur like built-in bar keys", async () => {
seedCustomShortcuts([{ id: "focus", label: "Focus", value: "echo focus" }]);
await renderMobile();
const button = screen.getByTestId("cli-terminal-custom-shortcut-focus");
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
button.dispatchEvent(pdEvent);
expect(pdEvent.defaultPrevented).toBe(true);
});
it("clears sticky Ctrl and injects the literal decoded shortcut value", async () => {
seedCustomShortcuts([{ id: "literal", label: "Literal", value: "c\\n" }]);
const { ws } = await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
fireEvent.click(ctrl);
expect(ctrl.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByTestId("cli-terminal-custom-shortcut-literal"));
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
expect(inputFrames(ws)).toEqual(["c\n"]);
});
it.each([
["read-only prop", { readOnly: true }, true],
["idle mode", { mode: "idle" as const }, false],
["ended mode", { mode: "ended" as const }, false],
["read-only attach ticket", {}, true],
])("suppresses custom shortcut buttons and input in %s", async (_label, props, ticketReadOnly) => {
seedCustomShortcuts([{ id: "blocked", label: "Blocked", value: "echo blocked\\n" }]);
if (ticketReadOnly) {
apiMock.mockResolvedValue({ ticket: "tkt-ro", expiresAt: "", readOnly: true });
}
const { ws } = await renderMobile(props);
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
expect(screen.queryByTestId("cli-terminal-custom-shortcut-blocked")).toBeNull();
expect(inputFrames(ws)).toEqual([]);
});
it("renders no custom shortcut shell for an empty list", async () => {
seedCustomShortcuts([]);
await renderMobile();
const keyBar = screen.getByTestId("cli-terminal-key-bar");
expect(screen.getByTestId("cli-key-esc")).toBeTruthy();
expect(keyBar.querySelector('[data-testid^="cli-terminal-custom-shortcut-"]')).toBeNull();
});
it("updates custom shortcut buttons from the shared storage event", async () => {
await renderMobile();
expect(screen.queryByTestId("cli-terminal-custom-shortcut-live")).toBeNull();
seedCustomShortcuts([{ id: "live", label: "Live", value: "echo live\\n" }]);
act(() => {
window.dispatchEvent(new StorageEvent("storage", { key: TERMINAL_PREFERENCES_KEY }));
});
await waitFor(() => {
expect(screen.getByTestId("cli-terminal-custom-shortcut-live").textContent).toBe("Live");
});
});
});
// ── AE6 mobile leg: same live bytes reach term.write ──────────────────────
it("mobile attach renders the same live session bytes (data → term.write)", async () => {
const { ws } = await renderMobile();