diff --git a/FEATURES.md b/FEATURES.md index e72acf3..5ff5bee 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -280,6 +280,17 @@ separate for desktop/mobile. Jump from a diff to its file preview. - Customize the floating `2×8` grid and up to four side buttons under **Configuration > Behavior > Mobile terminal shortcuts**. The Tabs sheet and pane controls work when the tab strip is hidden. + Choose **Preset** or **Custom keyboard**. The US keyboard lets you toggle Ctrl, + Alt, and Shift and pick one letter, number, symbol, or basic key; expand **More keys** + for navigation keys and F1-F12 in separate groups. Set a label and edit or clear individual slots; + defaults stay available. Buttons send only that combination, with no extra + Enter. Shift uses US symbols; Ctrl letters ignore case and can share bytes with + other keys, while Alt sends an Escape prefix. Modified Enter requires application + support. Custom PageUp/PageDown sends application input; the presets scroll + history. Unsupported combinations, such as Ctrl+1, identify the affected slot + and block saving. Buttons bypass browser keyboard + shortcuts, but application keybindings still apply. Cmd/Meta, text macros, + commands, and multi-step sequences are not supported. - Drag the `⋯` controls button to move the floating controls; release snaps them to the nearer side edge at that height, mirrored on the left. The position is kept per browser and stays clear of the header and tab strip. diff --git a/web/src/components/ConfigurationDialog.css b/web/src/components/ConfigurationDialog.css index 79d5231..a689c01 100644 --- a/web/src/components/ConfigurationDialog.css +++ b/web/src/components/ConfigurationDialog.css @@ -122,6 +122,7 @@ display: block; } +.configuration-modal .config-menu-item .config-item-copy, .configuration-modal .config-item-copy { text-align: left; gap: 3px; @@ -164,6 +165,10 @@ } :where(html[data-layout="mobile"]) { + .configuration-modal .modal-actions { + display: none; + } + .configuration-backdrop { padding: calc(12px + env(safe-area-inset-top, 0px)) 0 0; } diff --git a/web/src/components/ConfigurationDialog.tsx b/web/src/components/ConfigurationDialog.tsx index 8d3049f..697f426 100644 --- a/web/src/components/ConfigurationDialog.tsx +++ b/web/src/components/ConfigurationDialog.tsx @@ -43,7 +43,6 @@ import { import { AgentIntegrationsSettings } from "./AgentIntegrationsSettings"; import { AutoSyncRepositoriesDialog } from "./AutoSyncRepositoriesDialog"; import { CloseButton } from "./CloseButton"; -import { MobileTerminalShortcutsDialog } from "./MobileTerminalShortcutsDialog"; import { TerminalTransportSettings } from "./TerminalTransportSettings"; import { ConfigurationLoadingDialog } from "./ConfigurationLoadingDialog"; import { MobileSheetHandle } from "./MobileSheetHandle"; @@ -65,6 +64,11 @@ const MobileLayoutDialog = lazyWithReload("mobile-layout", () => default: module.MobileLayoutDialog, })), ); +const MobileTerminalShortcutsDialog = lazyWithReload("mobile-shortcuts", () => + import("./MobileTerminalShortcutsDialog").then((module) => ({ + default: module.MobileTerminalShortcutsDialog, + })), +); export type ConfigurationProps = { theme: Theme; @@ -645,15 +649,17 @@ export function ConfigurationDialog({ {detail === "layout" ? ( setDetail(null)} /> ) : null} + {detail === "mobile" ? ( + setDetail(null)} + /> + ) : null} - setDetail(null)} - /> setDetail(null)} diff --git a/web/src/components/MobileTerminalShortcutsDialog.css b/web/src/components/MobileTerminalShortcutsDialog.css index 5fb55c8..3b4dcc4 100644 --- a/web/src/components/MobileTerminalShortcutsDialog.css +++ b/web/src/components/MobileTerminalShortcutsDialog.css @@ -1,11 +1,21 @@ +/* Keep focus scrolling inside the dialog when the page uses CSS zoom. */ +html:has(.mobile-shortcuts-modal) { + overflow: clip; +} + .mobile-shortcuts-modal { width: min(760px, 100%); - max-height: min(760px, calc(100dvh - 36px)); + max-height: min(760px, calc(100dvh / var(--ui-scale, 1) - 36px)); display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; +} + +.mobile-shortcuts-modal > * { + flex-shrink: 0; } .mobile-shortcuts-modal .modal-head { @@ -236,6 +246,125 @@ padding: 0 8px; } +.mobile-shortcut-custom-fields { + grid-column: 1 / -1; + display: grid; + gap: 8px; +} + +.mobile-shortcut-mode { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.mobile-shortcut-mode button { + flex: 1; + min-height: 36px; + background: var(--input-bg); + color: var(--muted); +} + +.mobile-shortcut-keyboard-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px; + font-size: 11px; + color: var(--muted); +} + +.mobile-shortcut-keyboard-head output { + color: var(--text-strong); + font-weight: 600; + overflow-wrap: anywhere; +} + +.mobile-shortcut-modifiers { + display: flex; + gap: 4px; +} + +.mobile-shortcut-keyboard { + display: grid; + gap: 4px; +} + +.mobile-shortcut-keyboard-row { + display: flex; + justify-content: center; + gap: 3px; +} + +.mobile-shortcut-keycap { + flex: 1; + min-width: 0; + min-height: 36px; + max-width: calc((100% - 27px) / 10); + padding: 4px 0; + border-radius: 5px; + background: var(--input-bg); + color: var(--text-strong); + font: + 11px / 1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + touch-action: manipulation; +} + +.mobile-shortcut-basic-keys .mobile-shortcut-keycap, +.mobile-shortcut-modifiers .mobile-shortcut-keycap, +.mobile-shortcut-extra-keys .mobile-shortcut-keycap { + max-width: none; +} + +.mobile-shortcut-keycap[aria-pressed="true"], +.mobile-shortcut-mode button[aria-pressed="true"] { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.mobile-shortcut-extra-keys { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + margin-top: 6px; +} + +.mobile-shortcut-key-group { + margin-top: 8px; +} + +.mobile-shortcut-more-keys summary, +.mobile-shortcut-help summary { + padding: 8px 0; + cursor: pointer; + color: var(--muted); + font-size: 12px; +} + +.mobile-shortcut-help p, +.mobile-shortcut-error p { + margin: 0 0 4px; +} + +.mobile-shortcut-help, +.mobile-shortcut-error { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.mobile-shortcut-error { + color: var(--danger-text); +} + .mobile-shortcut-slot-editor-empty { min-height: 74px; display: flex; @@ -358,7 +487,8 @@ :where(html[data-layout="mobile"]) { .mobile-shortcuts-modal { max-height: calc( - 100dvh - + 100dvh / + var(--ui-scale, 1) - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) diff --git a/web/src/components/MobileTerminalShortcutsDialog.test.ts b/web/src/components/MobileTerminalShortcutsDialog.test.ts new file mode 100644 index 0000000..b3171b2 --- /dev/null +++ b/web/src/components/MobileTerminalShortcutsDialog.test.ts @@ -0,0 +1,184 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { + mobileTerminalShortcutBytes, + defaultMobileTerminalShortcutRows, + defaultMobileTerminalSideShortcuts, + type MobileTerminalShortcutRows, + type MobileTerminalSideShortcuts, +} from "../mobileTerminalShortcuts"; +import { MobileTerminalShortcutsDialog } from "./MobileTerminalShortcutsDialog"; + +// Isolate browser globals from the other tests and React's shared module graph. +if (process.env.ROAMGATE_SHORTCUT_PICKER_DOM_TEST !== "1") { + test("mobile shortcut picker interactions in an isolated runtime", async () => { + const child = Bun.spawn([process.execPath, "test", import.meta.path], { + env: { ...process.env, ROAMGATE_SHORTCUT_PICKER_DOM_TEST: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`${stdout}\n${stderr}`); + expect(code).toBe(0); + }, 15_000); +} else { + test("chooses multiple modifiers and one US key, validates and saves both areas", async () => { + const browser = new Window({ url: "http://localhost" }); + const originals = new Map(); + for (const [key, value] of Object.entries({ + window: browser, + document: browser.document, + navigator: browser.navigator, + HTMLElement: browser.HTMLElement, + Element: browser.Element, + Node: browser.Node, + requestAnimationFrame: browser.requestAnimationFrame.bind(browser), + cancelAnimationFrame: browser.cancelAnimationFrame.bind(browser), + IS_REACT_ACT_ENVIRONMENT: true, + })) { + originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + let savedRows: MobileTerminalShortcutRows = + defaultMobileTerminalShortcutRows(); + let savedSide: MobileTerminalSideShortcuts = + defaultMobileTerminalSideShortcuts(); + const button = (label: string) => + Array.from(container.querySelectorAll("button")).find( + (item) => + item.getAttribute("aria-label") === label || + item.textContent === label, + )!; + const click = async (label: string) => { + await act(async () => button(label).click()); + }; + try { + await act(async () => + root.render( + createElement(MobileTerminalShortcutsDialog, { + open: true, + rows: savedRows, + sideShortcuts: savedSide, + onChange: (rows) => { + savedRows = rows; + }, + onSideChange: (side) => { + savedSide = side; + }, + onClose: () => {}, + }), + ), + ); + expect( + container + .querySelector("details.mobile-shortcut-help") + ?.hasAttribute("open"), + ).toBe(false); + await click("Add button to row 1 slot 5"); + expect( + container + .querySelector('[role="combobox"]') + ?.getAttribute("aria-expanded"), + ).toBe("false"); + await click("Custom keyboard"); + await click("Preset"); + expect(button("Preset").getAttribute("aria-pressed")).toBe("true"); + await click("Custom keyboard"); + for (const key of "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + expect(button(`Key ${key}`)).toBeDefined(); + for (const key of "`-=[]\\;',./") + expect( + button(`Key ${key}`).closest(".mobile-shortcut-keyboard"), + ).not.toBeNull(); + expect( + Array.from( + container.querySelectorAll('[aria-label="Function keys"] button'), + (key) => key.textContent, + ), + ).toEqual(Array.from({ length: 12 }, (_, index) => `F${index + 1}`)); + expect( + container.querySelector( + '[aria-label="Navigation keys"] button[aria-label="Key F1"]', + ), + ).toBeNull(); + await click("Key 1"); + expect(button("Save shortcuts").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Row 1, slot 5: Ctrl+1 cannot be sent with the current terminal encoding.", + ); + await click("Edit row 1 slot 1, C-c, Ctrl+C"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Row 1, slot 5: Ctrl+1", + ); + expect(button("Save shortcuts").disabled).toBe(true); + await click("Edit row 1 slot 5, C-1, Ctrl+1"); + await click("Key X"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await click("Alt"); + await click("Shift"); + await click("Key /"); + expect(button("Key /").textContent).toBe("?"); + await click("Key Tab"); + expect(button("Save shortcuts").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Ctrl+Alt+Shift+Tab", + ); + await click("Key Y"); + expect(button("Key X").getAttribute("aria-pressed")).toBe("false"); + expect(button("Key Y").getAttribute("aria-pressed")).toBe("true"); + expect(container.querySelector("output")?.textContent).toBe( + "Ctrl+Alt+Shift+Y", + ); + expect(button("Save shortcuts").disabled).toBe(false); + await click("Add button to side slot 1"); + await click("Custom keyboard"); + await click("Ctrl"); + await click("Alt"); + await click("Shift"); + await click("Key 2"); + expect(button("Key 2").textContent).toBe("@"); + await click("Key ["); + expect(button("Key [").textContent).toBe("{"); + await click("Save shortcuts"); + expect(savedRows[0][4]?.action).toEqual({ + key: "y", + ctrl: true, + alt: true, + shift: true, + }); + expect(savedRows[0][4]?.label).toBe("C-A-S-Y"); + expect(mobileTerminalShortcutBytes(savedRows[0][4]!.action)).toEqual([ + 0x1b, 0x19, + ]); + expect(savedSide[0]?.action).toEqual({ + key: "[", + ctrl: false, + alt: true, + shift: true, + }); + expect(mobileTerminalShortcutBytes(savedSide[0]!.action)).toEqual([ + 0x1b, 0x7b, + ]); + } finally { + await act(async () => root.unmount()); + await browser.happyDOM.close(); + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + }); +} diff --git a/web/src/components/MobileTerminalShortcutsDialog.tsx b/web/src/components/MobileTerminalShortcutsDialog.tsx index aa4d20c..805635f 100644 --- a/web/src/components/MobileTerminalShortcutsDialog.tsx +++ b/web/src/components/MobileTerminalShortcutsDialog.tsx @@ -1,5 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { Check, ChevronsUpDown, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { + MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS, + mobileTerminalKeyCombinationBytes, + type MobileTerminalKeyCombination, +} from "../mobileTerminalKeyCombination"; import { MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW, MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS, @@ -28,6 +33,23 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import "./MobileTerminalShortcutsDialog.css"; const OPTION_GROUPS = ["Control", "Basic", "Navigation", "Modified"] as const; +const US_KEY_ROWS = [ + "1234567890", + "qwertyuiop", + "asdfghjkl", + "zxcvbnm", + "`-=[]\\", + ";',./", +]; +const BASIC_KEYS = ["Escape", "Tab", "Backspace", "Enter", "Space"]; +const EXTRA_KEY_GROUPS = { + "Navigation keys": MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS.filter( + (key) => !BASIC_KEYS.includes(key) && !/^F\d+$/.test(key), + ), + "Function keys": MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS.filter((key) => + /^F\d+$/.test(key), + ), +}; let nextShortcutId = 1; type SelectedSlot = @@ -78,35 +100,24 @@ function newShortcut(): MobileTerminalShortcut { function ShortcutKeySelect({ value, ariaLabel, - openRequest, onChange, }: { - value: MobileTerminalShortcutAction; + value: Extract; ariaLabel: string; - openRequest: number; onChange: (action: MobileTerminalShortcutAction) => void; }) { const currentItemRef = useRef(null); - const valueRef = useRef(value); - valueRef.current = value; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const [activeValue, setActiveValue] = useState(() => String(value)); + const [activeValue, setActiveValue] = useState(value); const currentOption = mobileTerminalShortcutOption(value); const setSelectorOpen = (next: boolean) => { setOpen(next); setSearch(""); - if (next) setActiveValue(valueRef.current); + if (next) setActiveValue(value); }; - useEffect(() => { - if (openRequest === 0) return; - setOpen(true); - setSearch(""); - setActiveValue(valueRef.current); - }, [openRequest]); - return ( @@ -117,7 +128,7 @@ function ShortcutKeySelect({ aria-expanded={open} aria-label={ariaLabel} > - {currentOption?.label ?? value} + {currentOption?.label} @@ -186,6 +197,109 @@ function ShortcutKeySelect({ ); } +function CustomKeyPicker({ + value, + onChange, +}: { + value: MobileTerminalKeyCombination; + onChange: (action: MobileTerminalKeyCombination) => void; +}) { + const keyButton = (key: string) => { + const shiftedKey = + key.length === 1 + ? String.fromCharCode( + ...mobileTerminalKeyCombinationBytes({ + key, + ctrl: false, + alt: false, + shift: true, + }), + ) + : key; + return ( + + ); + }; + + return ( +
+
+ US keyboard + + {mobileTerminalShortcutOption(value)?.label} + +
+
+ {(["ctrl", "alt", "shift"] as const).map((modifier) => ( + + ))} +
+
+ {US_KEY_ROWS.map((row) => ( +
+ {Array.from(row, keyButton)} +
+ ))} +
+ {BASIC_KEYS.map(keyButton)} +
+
+
+ More keys + {Object.entries(EXTRA_KEY_GROUPS).map(([group, keys]) => ( +
+
{group}
+
+ {keys.map(keyButton)} +
+
+ ))} +
+
+ ); +} + export function MobileTerminalShortcutsDialog({ open, rows, @@ -215,14 +329,12 @@ export function MobileTerminalShortcutsDialog({ cloneSideShortcuts(sideShortcuts), ); const [selectedSlot, setSelectedSlot] = useState(null); - const [keySelectorOpenRequest, setKeySelectorOpenRequest] = useState(0); useEffect(() => { if (!open) return; setDraft(cloneRows(rowsRef.current)); setSideDraft(cloneSideShortcuts(sideShortcutsRef.current)); setSelectedSlot(null); - setKeySelectorOpenRequest(0); const cancelFocus = focusDialogElement(dialogRef.current); const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; @@ -254,7 +366,6 @@ export function MobileTerminalShortcutsDialog({ return next; }); setSelectedSlot({ area: "panel", rowIndex, slotIndex }); - setKeySelectorOpenRequest((request) => request + 1); }; const selectSideSlot = (slotIndex: number) => { @@ -265,7 +376,6 @@ export function MobileTerminalShortcutsDialog({ return next; }); setSelectedSlot({ area: "side", slotIndex }); - setKeySelectorOpenRequest((request) => request + 1); }; const updateSelectedShortcut = ( @@ -309,14 +419,47 @@ export function MobileTerminalShortcutsDialog({ setSelectedSlot(null); }; + const updateSelectedAction = (action: MobileTerminalShortcutAction) => { + const nextOption = mobileTerminalShortcutOption(action); + updateSelectedShortcut((current) => ({ + ...current, + action, + label: + !current.label.trim() || + current.label === + mobileTerminalShortcutOption(current.action)?.defaultButtonLabel + ? (nextOption?.defaultButtonLabel ?? current.label) + : current.label, + })); + }; + const selectedShortcut = selectedSlot ? selectedSlot.area === "side" ? sideDraft[selectedSlot.slotIndex] : draft[selectedSlot.rowIndex][selectedSlot.slotIndex] : null; - const selectedOption = selectedShortcut - ? mobileTerminalShortcutOption(selectedShortcut.action) - : null; + const customAction = + selectedShortcut && typeof selectedShortcut.action === "object" + ? selectedShortcut.action + : null; + const invalidCombinations = [ + ...draft.flatMap((row, rowIndex) => + row.map( + (shortcut, slotIndex) => + [shortcut, `Row ${rowIndex + 1}, slot ${slotIndex + 1}`] as const, + ), + ), + ...sideDraft.map( + (shortcut, slotIndex) => + [shortcut, `Right-side slot ${slotIndex + 1}`] as const, + ), + ].flatMap(([shortcut, location]) => + shortcut && + typeof shortcut.action === "object" && + !mobileTerminalKeyCombinationBytes(shortcut.action).length + ? [`${location}: ${mobileTerminalShortcutOption(shortcut.action)?.label}`] + : [], + ); return (
@@ -332,10 +475,7 @@ export function MobileTerminalShortcutsDialog({

Mobile Terminal Shortcuts

-

- Select any slot to add or edit a button. Configure the 2-by-8 - panel and up to four right-side buttons. -

+

Choose a slot, then a preset or custom key.

@@ -370,13 +510,13 @@ export function MobileTerminalShortcutsDialog({ } ${selected ? "is-selected" : ""}`} aria-label={ shortcut - ? `Edit row ${rowIndex + 1} slot ${slotIndex + 1}, ${shortcut.label}, ${option?.label ?? shortcut.action}` + ? `Edit row ${rowIndex + 1} slot ${slotIndex + 1}, ${shortcut.label}, ${option?.label ?? "Unknown key"}` : `Add button to row ${rowIndex + 1} slot ${slotIndex + 1}` } aria-pressed={selected} title={ shortcut - ? `${shortcut.label} · ${option?.label ?? shortcut.action}` + ? `${shortcut.label} · ${option?.label ?? "Unknown key"}` : `Add button to slot ${slotIndex + 1}` } onClick={() => selectPanelSlot(rowIndex, slotIndex)} @@ -385,7 +525,7 @@ export function MobileTerminalShortcutsDialog({ {shortcut ? ( <> {shortcut.label} - {option?.label ?? shortcut.action} + {option?.label ?? "Unknown key"} ) : ( <> @@ -428,13 +568,13 @@ export function MobileTerminalShortcutsDialog({ } ${selected ? "is-selected" : ""}`} aria-label={ shortcut - ? `Edit side slot ${slotIndex + 1}, ${shortcut.label}, ${option?.label ?? shortcut.action}` + ? `Edit side slot ${slotIndex + 1}, ${shortcut.label}, ${option?.label ?? "Unknown key"}` : `Add button to side slot ${slotIndex + 1}` } aria-pressed={selected} title={ shortcut - ? `${shortcut.label} · ${option?.label ?? shortcut.action}` + ? `${shortcut.label} · ${option?.label ?? "Unknown key"}` : `Add side button ${slotIndex + 1}` } onClick={() => selectSideSlot(slotIndex)} @@ -443,7 +583,7 @@ export function MobileTerminalShortcutsDialog({ {shortcut ? ( <> {shortcut.label} - {option?.label ?? shortcut.action} + {option?.label ?? "Unknown key"} ) : ( <> @@ -461,7 +601,6 @@ export function MobileTerminalShortcutsDialog({ className={`mobile-shortcut-slot-editor ${ selectedShortcut ? "is-active" : "" }`} - aria-live="polite" > {selectedShortcut && selectedSlot ? ( <> @@ -483,6 +622,36 @@ export function MobileTerminalShortcutsDialog({ Clear slot
+
+ + +
-
- Key - { - const nextOption = mobileTerminalShortcutOption(action); - updateSelectedShortcut((current) => ({ - ...current, - action, - label: - !current.label.trim() || - current.label === selectedOption?.defaultButtonLabel - ? (nextOption?.defaultButtonLabel ?? current.label) - : current.label, - })); - }} + {typeof selectedShortcut.action === "string" ? ( +
+ Key + +
+ ) : null} + {customAction ? ( + -
+ ) : null}
) : ( @@ -536,6 +701,32 @@ export function MobileTerminalShortcutsDialog({ )} + {invalidCombinations.length > 0 ? ( +
+ {invalidCombinations.map((combination) => ( +

+ {combination} cannot be sent with the current terminal encoding. +

+ ))} +

Change the key or modifiers before saving.

+
+ ) : null} +
+ How shortcuts work +

+ Each button sends one key combination, with no extra Enter. Shift + uses US key symbols. Ctrl+letter ignores case; some combinations + share the same bytes (Ctrl+I/Tab, Ctrl+M/Enter). Alt sends an Escape + prefix. Modified Enter needs application support. Custom + PageUp/PageDown sends keys to the application; the presets scroll + history. Buttons bypass browser keyboard shortcuts, but the terminal + application decides how to handle them. Some Ctrl+number/symbol + combinations, Ctrl/Alt+Tab, Ctrl/Shift+Escape, and Shift+Backspace + cannot be encoded. Cmd/Meta, text macros, and multi-step sequences + are not supported. +

+
+