From 8a612292a81e5b130be8d70e98548a72929ba702 Mon Sep 17 00:00:00 2001
From: Wangshuyi
Date: Wed, 30 Sep 2026 22:04:50 +0800
Subject: [PATCH 1/7] Support custom mobile terminal key combinations
---
FEATURES.md | 9 +
.../MobileTerminalShortcutsDialog.css | 41 ++++-
.../MobileTerminalShortcutsDialog.tsx | 167 +++++++++++++++---
web/src/mobileTerminalKeyCombination.ts | 118 +++++++++++++
web/src/mobileTerminalShortcuts.test.ts | 90 ++++++++++
web/src/mobileTerminalShortcuts.ts | 53 ++++--
6 files changed, 440 insertions(+), 38 deletions(-)
create mode 100644 web/src/mobileTerminalKeyCombination.ts
diff --git a/FEATURES.md b/FEATURES.md
index e72acf33..4e4cd5bd 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -280,6 +280,15 @@ 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 a preset or **Custom combination** to set an ASCII key, navigation key,
+ or F1-F12 with Ctrl, Alt, and Shift, plus a label. 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 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/MobileTerminalShortcutsDialog.css b/web/src/components/MobileTerminalShortcutsDialog.css
index 5fb55c8b..1904f4be 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.css
+++ b/web/src/components/MobileTerminalShortcutsDialog.css
@@ -220,6 +220,7 @@
}
.mobile-shortcut-slot-editor-fields input,
+.mobile-shortcut-slot-editor-fields select,
.mobile-shortcut-key-trigger {
width: 100%;
min-width: 0;
@@ -232,10 +233,48 @@
font: 12px / 1.2 inherit;
}
-.mobile-shortcut-slot-editor-fields input {
+.mobile-shortcut-slot-editor-fields input,
+.mobile-shortcut-slot-editor-fields select {
padding: 0 8px;
}
+.mobile-shortcut-custom-fields {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 8px;
+}
+
+.mobile-shortcut-modifiers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.mobile-shortcut-modifiers label {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ text-transform: none;
+}
+
+.mobile-shortcut-modifiers input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent);
+}
+
+.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;
diff --git a/web/src/components/MobileTerminalShortcutsDialog.tsx b/web/src/components/MobileTerminalShortcutsDialog.tsx
index aa4d20c4..49d4ccfa 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.tsx
+++ b/web/src/components/MobileTerminalShortcutsDialog.tsx
@@ -1,5 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronsUpDown, Plus, RotateCcw, Trash2 } from "lucide-react";
+import {
+ MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS,
+ mobileTerminalKeyCombinationBytes,
+} from "../mobileTerminalKeyCombination";
import {
MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW,
MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS,
@@ -28,6 +32,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import "./MobileTerminalShortcutsDialog.css";
const OPTION_GROUPS = ["Control", "Basic", "Navigation", "Modified"] as const;
+const CUSTOM_KEY_GROUPS = {
+ Letters: Array.from("abcdefghijklmnopqrstuvwxyz"),
+ Numbers: Array.from("0123456789"),
+ Symbols: Array.from("`-=[]\\;',./~!@#$%^&*()_+{}|:\"<>?"),
+ Special: MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS,
+};
let nextShortcutId = 1;
type SelectedSlot =
@@ -91,20 +101,27 @@ function ShortcutKeySelect({
valueRef.current = value;
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
- const [activeValue, setActiveValue] = useState(() => String(value));
+ const [activeValue, setActiveValue] = useState(() =>
+ typeof value === "string" ? value : "custom",
+ );
const currentOption = mobileTerminalShortcutOption(value);
const setSelectorOpen = (next: boolean) => {
setOpen(next);
setSearch("");
- if (next) setActiveValue(valueRef.current);
+ if (next)
+ setActiveValue(
+ typeof valueRef.current === "string" ? valueRef.current : "custom",
+ );
};
useEffect(() => {
if (openRequest === 0) return;
setOpen(true);
setSearch("");
- setActiveValue(valueRef.current);
+ setActiveValue(
+ typeof valueRef.current === "string" ? valueRef.current : "custom",
+ );
}, [openRequest]);
return (
@@ -117,7 +134,7 @@ function ShortcutKeySelect({
aria-expanded={open}
aria-label={ariaLabel}
>
- {currentOption?.label ?? value}
+ {currentOption?.label}
@@ -146,6 +163,24 @@ function ShortcutKeySelect({
/>
No matching keys.
+
+ {
+ onChange(
+ typeof value === "object"
+ ? value
+ : { key: "x", ctrl: true, alt: false, shift: false },
+ );
+ setSelectorOpen(false);
+ }}
+ >
+ Custom combination
+
+
{OPTION_GROUPS.map((group) => (
{MOBILE_TERMINAL_SHORTCUT_OPTIONS.filter(
@@ -309,14 +344,35 @@ 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 hasInvalidCombination = [...draft.flat(), ...sideDraft].some(
+ (shortcut) =>
+ shortcut &&
+ typeof shortcut.action === "object" &&
+ !mobileTerminalKeyCombinationBytes(shortcut.action).length,
+ );
return (
@@ -370,13 +426,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 +441,7 @@ export function MobileTerminalShortcutsDialog({
{shortcut ? (
<>
{shortcut.label}
- {option?.label ?? shortcut.action}
+ {option?.label ?? "Unknown key"}
>
) : (
<>
@@ -428,13 +484,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 +499,7 @@ export function MobileTerminalShortcutsDialog({
{shortcut ? (
<>
{shortcut.label}
- {option?.label ?? shortcut.action}
+ {option?.label ?? "Unknown key"}
>
) : (
<>
@@ -512,20 +568,62 @@ export function MobileTerminalShortcutsDialog({
? `Side slot ${selectedSlot.slotIndex + 1} key`
: `Row ${selectedSlot.rowIndex + 1} slot ${selectedSlot.slotIndex + 1} key`
}
- onChange={(action) => {
- const nextOption = mobileTerminalShortcutOption(action);
- updateSelectedShortcut((current) => ({
- ...current,
- action,
- label:
- !current.label.trim() ||
- current.label === selectedOption?.defaultButtonLabel
- ? (nextOption?.defaultButtonLabel ?? current.label)
- : current.label,
- }));
- }}
+ onChange={updateSelectedAction}
/>
+ {customAction ? (
+
+
+ Custom key
+ {
+ const key = event.target.value;
+ updateSelectedAction({ ...customAction, key });
+ }}
+ >
+ {Object.entries(CUSTOM_KEY_GROUPS).map(
+ ([group, keys]) => (
+
+ {keys.map((key) => (
+
+ {key}
+
+ ))}
+
+ ),
+ )}
+
+
+
+ {(["ctrl", "alt", "shift"] as const).map((modifier) => (
+
+ {
+ const checked = event.target.checked;
+ updateSelectedAction({
+ ...customAction,
+ [modifier]: checked,
+ });
+ }}
+ />
+ {modifier === "ctrl"
+ ? "Ctrl"
+ : modifier === "alt"
+ ? "Alt"
+ : "Shift"}
+
+ ))}
+
+
+ ) : null}
>
) : (
@@ -536,6 +634,24 @@ export function MobileTerminalShortcutsDialog({
)}
+ {hasInvalidCombination ? (
+
+ A custom combination is unsupported. Ctrl/Alt+Tab,
+ Ctrl/Shift+Escape, Shift+Backspace, and Ctrl with some symbols have
+ no supported encoding. Change the key or modifiers before saving.
+
+ ) : null}
+
+ 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. Cmd/Meta, text macros, and multi-step sequences are
+ not supported.
+
+
{
onChange(normalizeMobileTerminalShortcutRows(draft));
onSideChange(normalizeMobileTerminalSideShortcuts(sideDraft));
diff --git a/web/src/mobileTerminalKeyCombination.ts b/web/src/mobileTerminalKeyCombination.ts
new file mode 100644
index 00000000..4bfa3b87
--- /dev/null
+++ b/web/src/mobileTerminalKeyCombination.ts
@@ -0,0 +1,118 @@
+export type MobileTerminalKeyCombination = {
+ key: string;
+ ctrl: boolean;
+ alt: boolean;
+ shift: boolean;
+};
+
+const cursorKeys: Record = {
+ ArrowUp: "A",
+ ArrowDown: "B",
+ ArrowRight: "C",
+ ArrowLeft: "D",
+ Home: "H",
+ End: "F",
+};
+const functionKeys: Record = {
+ Insert: 2,
+ Delete: 3,
+ PageUp: 5,
+ PageDown: 6,
+ F5: 15,
+ F6: 17,
+ F7: 18,
+ F8: 19,
+ F9: 20,
+ F10: 21,
+ F11: 23,
+ F12: 24,
+};
+export const MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS = [
+ "Space",
+ "Enter",
+ "Tab",
+ "Escape",
+ "Backspace",
+ ...Object.keys(cursorKeys),
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ ...Object.keys(functionKeys),
+];
+const supportedKeys = new Set(MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS);
+
+/** Fixed xterm-style sequences, matching the mobile presets; no text or macros. */
+export function mobileTerminalKeyCombinationBytes(value: unknown): number[] {
+ if (!value || typeof value !== "object") return [];
+ const raw = value as Record;
+ if (
+ typeof raw.key !== "string" ||
+ (!/^[!-~]$/.test(raw.key) && !supportedKeys.has(raw.key)) ||
+ [raw.ctrl, raw.alt, raw.shift].some(
+ (modifier) => typeof modifier !== "boolean",
+ ) ||
+ Object.keys(raw).some(
+ (key) => !["key", "ctrl", "alt", "shift"].includes(key),
+ )
+ )
+ return [];
+ const { key, ctrl, alt, shift } = raw as MobileTerminalKeyCombination;
+ const modifier = 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0);
+ const suffix = modifier === 1 ? "" : `;${modifier}`;
+ let sequence: string;
+ if (cursorKeys[key]) {
+ sequence = `\x1b[${modifier === 1 ? "" : `1${suffix}`}${cursorKeys[key]}`;
+ } else if (/^F[1-4]$/.test(key)) {
+ const final = "PQRS"[Number(key.slice(1)) - 1];
+ sequence = modifier === 1 ? `\x1bO${final}` : `\x1b[1${suffix}${final}`;
+ } else if (functionKeys[key]) {
+ sequence = `\x1b[${functionKeys[key]}${suffix}~`;
+ } else if (key === "Enter") {
+ sequence = modifier === 1 ? "\r" : `\x1b[13${suffix}u`;
+ } else {
+ if (key === "Tab") {
+ if (ctrl || alt) return [];
+ sequence = shift ? "\x1b[Z" : "\t";
+ } else if (key === "Escape") {
+ if (ctrl || shift) return [];
+ sequence = "\x1b";
+ } else if (key === "Backspace") {
+ if (shift) return [];
+ sequence = ctrl ? "\b" : "\x7f";
+ } else {
+ sequence = key === "Space" ? " " : key;
+ if (shift) {
+ const index = "`1234567890-=[]\\;',./".indexOf(sequence);
+ sequence =
+ index >= 0 ? '~!@#$%^&*()_+{}|:"<>?'[index] : sequence.toUpperCase();
+ }
+ if (ctrl) {
+ const code = sequence.toUpperCase().charCodeAt(0);
+ if (code >= 0x40 && code <= 0x5f)
+ sequence = String.fromCharCode(code - 0x40);
+ else if (sequence === " " || sequence === "2") sequence = "\x00";
+ else if (sequence === "/") sequence = "\x1f";
+ else if (/^[3-7]$/.test(sequence))
+ sequence = String.fromCharCode(Number(sequence) + 24);
+ else if (sequence === "8" || sequence === "?") sequence = "\x7f";
+ else return [];
+ }
+ }
+ if (alt) sequence = `\x1b${sequence}`;
+ }
+ return Array.from(sequence, (character) => character.charCodeAt(0));
+}
+
+export function mobileTerminalKeyCombinationLabel(
+ action: MobileTerminalKeyCombination,
+): string {
+ return [
+ action.ctrl && "Ctrl",
+ action.alt && "Alt",
+ action.shift && "Shift",
+ action.key.length === 1 ? action.key.toUpperCase() : action.key,
+ ]
+ .filter(Boolean)
+ .join("+");
+}
diff --git a/web/src/mobileTerminalShortcuts.test.ts b/web/src/mobileTerminalShortcuts.test.ts
index 54673d9c..236ead9f 100644
--- a/web/src/mobileTerminalShortcuts.test.ts
+++ b/web/src/mobileTerminalShortcuts.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
+import { mobileTerminalKeyCombinationBytes } from "./mobileTerminalKeyCombination";
+import { mobileTerminalShortcutExecution } from "./mobileTerminalShortcutAction";
import {
MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW,
defaultMobileTerminalShortcutRows,
@@ -14,6 +16,94 @@ import {
} from "./mobileTerminalShortcuts";
describe("mobile terminal shortcuts", () => {
+ test.each([
+ ["x", true, false, false, "\x18"],
+ ["j", true, false, false, "\n"],
+ ["x", false, true, false, "\x1bx"],
+ ["x", true, true, false, "\x1b\x18"],
+ ["x", false, false, true, "X"],
+ ["2", false, true, true, "\x1b@"],
+ ["6", true, false, true, "\x1e"],
+ ["[", true, false, false, "\x1b"],
+ ["/", true, false, false, "\x1f"],
+ ["/", true, true, false, "\x1b\x1f"],
+ ["Space", true, false, false, "\x00"],
+ ["3", true, false, false, "\x1b"],
+ ["8", true, false, false, "\x7f"],
+ ["Tab", false, false, true, "\x1b[Z"],
+ ["Escape", false, true, false, "\x1b\x1b"],
+ ["Backspace", true, true, false, "\x1b\b"],
+ ["ArrowUp", false, false, false, "\x1b[A"],
+ ["ArrowLeft", true, true, true, "\x1b[1;8D"],
+ ["Home", true, false, false, "\x1b[1;5H"],
+ ["F1", false, false, false, "\x1bOP"],
+ ["F4", false, true, false, "\x1b[1;3S"],
+ ["F12", true, false, false, "\x1b[24;5~"],
+ ["Delete", false, false, true, "\x1b[3;2~"],
+ ["PageUp", false, false, false, "\x1b[5~"],
+ ["PageDown", false, true, false, "\x1b[6;3~"],
+ ["Enter", false, false, false, "\r"],
+ ["Enter", true, false, false, "\x1b[13;5u"],
+ ["Enter", false, false, true, "\x1b[13;2u"],
+ ] as const)(
+ "sends custom %s (Ctrl=%s Alt=%s Shift=%s) with no extra Enter",
+ (key, ctrl, alt, shift, sequence) => {
+ const action = { key, ctrl, alt, shift };
+ const bytes = Array.from(sequence, (character) =>
+ character.charCodeAt(0),
+ );
+ expect(mobileTerminalKeyCombinationBytes(action)).toEqual(bytes);
+ expect(mobileTerminalShortcutExecution(action)).toEqual({
+ type: "input",
+ bytes,
+ });
+ },
+ );
+
+ test("round-trips custom panel and side buttons while rejecting unsupported stored actions", () => {
+ const rows = defaultMobileTerminalShortcutRows();
+ const action = { key: "o", ctrl: true, alt: true, shift: false };
+ const shortcut = { id: "custom-o", label: "Open", action };
+ rows[0][4] = shortcut;
+ expect(
+ parseMobileTerminalShortcutRows(
+ serializeMobileTerminalShortcutRows(rows),
+ ),
+ ).toEqual(rows);
+ expect(
+ parseMobileTerminalSideShortcuts(
+ serializeMobileTerminalSideShortcuts([null, shortcut, null, null]),
+ ),
+ ).toEqual([null, shortcut, null, null]);
+ const invalidActions = [
+ { ...action, key: "hello" },
+ { ...action, key: "\x1b" },
+ { ...action, key: "你" },
+ { ...action, key: "constructor" },
+ { ...action, key: "toString" },
+ { ...action, key: "1" },
+ { ...action, key: "Tab" },
+ { ...action, key: "Escape" },
+ { ...action, key: "Backspace", shift: true },
+ { ...action, ctrl: "true" },
+ { key: "x" },
+ { ...action, meta: true },
+ { ...action, bytes: [0x0d] },
+ null,
+ [],
+ ];
+ for (const action of invalidActions) {
+ expect(mobileTerminalKeyCombinationBytes(action)).toEqual([]);
+ const candidate = { ...shortcut, action };
+ expect(
+ normalizeMobileTerminalShortcutRows([[null, candidate], []])[0][1],
+ ).toBeNull();
+ expect(
+ parseMobileTerminalSideShortcuts(JSON.stringify([null, candidate]))[1],
+ ).toBeNull();
+ }
+ });
+
test("uses the terminal controls across at most two aligned default rows", () => {
const rows = defaultMobileTerminalShortcutRows();
diff --git a/web/src/mobileTerminalShortcuts.ts b/web/src/mobileTerminalShortcuts.ts
index 9c634242..b16fe276 100644
--- a/web/src/mobileTerminalShortcuts.ts
+++ b/web/src/mobileTerminalShortcuts.ts
@@ -1,3 +1,9 @@
+import {
+ mobileTerminalKeyCombinationBytes,
+ mobileTerminalKeyCombinationLabel,
+ type MobileTerminalKeyCombination,
+} from "./mobileTerminalKeyCombination";
+
export const MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY =
"mobileTerminalShortcuts.v2";
export const LEGACY_MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY =
@@ -276,8 +282,11 @@ export const MOBILE_TERMINAL_SHORTCUT_OPTIONS = [
},
] as const satisfies readonly MobileTerminalShortcutOptionDefinition[];
-export type MobileTerminalShortcutAction =
+type MobileTerminalPresetAction =
(typeof MOBILE_TERMINAL_SHORTCUT_OPTIONS)[number]["id"];
+export type MobileTerminalShortcutAction =
+ | MobileTerminalPresetAction
+ | MobileTerminalKeyCombination;
export type MobileTerminalShortcut = {
id: string;
@@ -295,7 +304,7 @@ export type MobileTerminalShortcutRows = [
export type MobileTerminalSideShortcuts = MobileTerminalShortcutSlot[];
const optionById = new Map<
- MobileTerminalShortcutAction,
+ MobileTerminalPresetAction,
MobileTerminalShortcutOptionDefinition
>(MOBILE_TERMINAL_SHORTCUT_OPTIONS.map((option) => [option.id, option]));
@@ -336,20 +345,29 @@ export function defaultMobileTerminalSideShortcuts(): MobileTerminalSideShortcut
export function mobileTerminalShortcutOption(
action: MobileTerminalShortcutAction,
-) {
- return optionById.get(action) ?? null;
+): MobileTerminalShortcutOptionDefinition | null {
+ if (typeof action === "string") return optionById.get(action) ?? null;
+ const bytes = mobileTerminalKeyCombinationBytes(action);
+ const label = mobileTerminalKeyCombinationLabel(action);
+ return {
+ id: "custom",
+ label,
+ defaultButtonLabel: clipLabel(label),
+ group: "Modified",
+ bytes,
+ };
}
export function mobileTerminalShortcutBytes(
action: MobileTerminalShortcutAction,
): number[] {
- return [...(optionById.get(action)?.bytes ?? [])];
+ return [...(mobileTerminalShortcutOption(action)?.bytes ?? [])];
}
export function mobileTerminalShortcutScroll(
action: MobileTerminalShortcutAction,
): { direction: "up" | "down"; amount: "full" | "half" } | null {
- const scroll = optionById.get(action)?.scroll;
+ const scroll = mobileTerminalShortcutOption(action)?.scroll;
return scroll ? { ...scroll } : null;
}
@@ -359,6 +377,17 @@ function clipLabel(value: string): string {
.join("");
}
+function normalizeAction(value: unknown): MobileTerminalShortcutAction | null {
+ if (typeof value === "string") {
+ return optionById.has(value as MobileTerminalPresetAction)
+ ? (value as MobileTerminalPresetAction)
+ : null;
+ }
+ if (!mobileTerminalKeyCombinationBytes(value).length) return null;
+ const { key, ctrl, alt, shift } = value as MobileTerminalKeyCombination;
+ return { key, ctrl, alt, shift };
+}
+
function normalizedId(
value: unknown,
rowIndex: number,
@@ -413,9 +442,9 @@ export function normalizeMobileTerminalShortcutRows(
const candidate = sourceRow[sourceIndex];
if (!candidate || typeof candidate !== "object") continue;
const raw = candidate as Record;
- if (typeof raw.action !== "string") continue;
- const action = raw.action as MobileTerminalShortcutAction;
- const option = optionById.get(action);
+ const action = normalizeAction(raw.action);
+ if (!action) continue;
+ const option = mobileTerminalShortcutOption(action);
if (!option) continue;
const label =
typeof raw.label === "string" && clipLabel(raw.label)
@@ -448,9 +477,9 @@ export function normalizeMobileTerminalSideShortcuts(
const candidate = value[slotIndex];
if (!candidate || typeof candidate !== "object") continue;
const raw = candidate as Record;
- if (typeof raw.action !== "string") continue;
- const action = raw.action as MobileTerminalShortcutAction;
- const option = optionById.get(action);
+ const action = normalizeAction(raw.action);
+ if (!action) continue;
+ const option = mobileTerminalShortcutOption(action);
if (!option) continue;
shortcuts[slotIndex] = {
id: normalizedId(raw.id, 2, slotIndex, usedIds),
From b2cff51bcd0e4815f806de095bcc41344e5420c8 Mon Sep 17 00:00:00 2001
From: Wangshuyi
Date: Wed, 30 Sep 2026 22:18:03 +0800
Subject: [PATCH 2/7] Keep custom terminal shortcut labels distinct
---
web/src/mobileTerminalKeyCombination.ts | 17 ++++++-----
web/src/mobileTerminalShortcuts.test.ts | 39 +++++++++++++++++++++++++
web/src/mobileTerminalShortcuts.ts | 8 ++++-
3 files changed, 56 insertions(+), 8 deletions(-)
diff --git a/web/src/mobileTerminalKeyCombination.ts b/web/src/mobileTerminalKeyCombination.ts
index 4bfa3b87..fe3a1cd6 100644
--- a/web/src/mobileTerminalKeyCombination.ts
+++ b/web/src/mobileTerminalKeyCombination.ts
@@ -104,14 +104,17 @@ export function mobileTerminalKeyCombinationBytes(value: unknown): number[] {
return Array.from(sequence, (character) => character.charCodeAt(0));
}
-export function mobileTerminalKeyCombinationLabel(
- action: MobileTerminalKeyCombination,
-): string {
+export function mobileTerminalKeyCombinationLabel({
+ key,
+ ctrl,
+ alt,
+ shift,
+}: MobileTerminalKeyCombination): string {
return [
- action.ctrl && "Ctrl",
- action.alt && "Alt",
- action.shift && "Shift",
- action.key.length === 1 ? action.key.toUpperCase() : action.key,
+ ctrl && "Ctrl",
+ alt && "Alt",
+ shift && "Shift",
+ key.length === 1 && (ctrl || shift) ? key.toUpperCase() : key,
]
.filter(Boolean)
.join("+");
diff --git a/web/src/mobileTerminalShortcuts.test.ts b/web/src/mobileTerminalShortcuts.test.ts
index 236ead9f..f9adbc73 100644
--- a/web/src/mobileTerminalShortcuts.test.ts
+++ b/web/src/mobileTerminalShortcuts.test.ts
@@ -7,6 +7,7 @@ import {
defaultMobileTerminalSideShortcuts,
mobileTerminalShortcutBytes,
mobileTerminalShortcutCount,
+ mobileTerminalShortcutOption,
mobileTerminalShortcutScroll,
normalizeMobileTerminalShortcutRows,
parseMobileTerminalShortcutRows,
@@ -16,6 +17,44 @@ import {
} from "./mobileTerminalShortcuts";
describe("mobile terminal shortcuts", () => {
+ test("keeps case-sensitive character labels consistent with sent bytes", () => {
+ for (const key of ["x", "X"]) {
+ for (const alt of [false, true]) {
+ const action = { key, ctrl: false, alt, shift: false };
+ expect(mobileTerminalShortcutOption(action)?.label).toBe(
+ `${alt ? "Alt+" : ""}${key}`,
+ );
+ expect(mobileTerminalShortcutBytes(action)).toEqual([
+ ...(alt ? [0x1b] : []),
+ key.charCodeAt(0),
+ ]);
+ }
+ }
+ });
+
+ test("keeps the key visible in default labels with multiple modifiers", () => {
+ for (const [key, expected] of [
+ ["x", "C-A-S-X"],
+ ["y", "C-A-S-Y"],
+ ["ArrowLeft", "C-A-S-Left"],
+ ["ArrowRight", "C-A-S-Righ"],
+ ["PageUp", "C-A-S-PgUp"],
+ ["PageDown", "C-A-S-PgDn"],
+ ]) {
+ const action = { key, ctrl: true, alt: true, shift: true };
+ expect(mobileTerminalShortcutOption(action)?.defaultButtonLabel).toBe(
+ expected,
+ );
+ const shortcut = { id: "custom", label: "", action };
+ expect(
+ normalizeMobileTerminalShortcutRows([[shortcut], []])[0][0]?.label,
+ ).toBe(expected);
+ expect(
+ parseMobileTerminalSideShortcuts(JSON.stringify([shortcut]))[0]?.label,
+ ).toBe(expected);
+ }
+ });
+
test.each([
["x", true, false, false, "\x18"],
["j", true, false, false, "\n"],
diff --git a/web/src/mobileTerminalShortcuts.ts b/web/src/mobileTerminalShortcuts.ts
index b16fe276..16eccc83 100644
--- a/web/src/mobileTerminalShortcuts.ts
+++ b/web/src/mobileTerminalShortcuts.ts
@@ -352,7 +352,13 @@ export function mobileTerminalShortcutOption(
return {
id: "custom",
label,
- defaultButtonLabel: clipLabel(label),
+ defaultButtonLabel: clipLabel(
+ label
+ .replace(/(trl|lt|hift)\+/g, "-")
+ .replace("Arrow", "")
+ .replace("Page", "Pg")
+ .replace("Down", "Dn"),
+ ),
group: "Modified",
bytes,
};
From ea752055bea3bc1d8d50e8c8535d25b3fb7ecf97 Mon Sep 17 00:00:00 2001
From: Wangshuyi
Date: Wed, 30 Sep 2026 22:22:49 +0800
Subject: [PATCH 3/7] Left-align configuration drawer action labels
---
web/src/components/ConfigurationDialog.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/web/src/components/ConfigurationDialog.css b/web/src/components/ConfigurationDialog.css
index 79d52318..853e17b9 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;
From 4e2df0f88f77cb9f1266448e8ef5b16c4e325333 Mon Sep 17 00:00:00 2001
From: Wangshuyi
Date: Wed, 30 Sep 2026 22:52:11 +0800
Subject: [PATCH 4/7] Use a keyboard picker for custom mobile shortcuts
---
FEATURES.md | 5 +-
web/src/components/ConfigurationDialog.tsx | 24 +-
.../MobileTerminalShortcutsDialog.css | 116 ++++++-
.../MobileTerminalShortcutsDialog.test.ts | 149 +++++++++
.../MobileTerminalShortcutsDialog.tsx | 306 ++++++++++--------
5 files changed, 444 insertions(+), 156 deletions(-)
create mode 100644 web/src/components/MobileTerminalShortcutsDialog.test.ts
diff --git a/FEATURES.md b/FEATURES.md
index 4e4cd5bd..1bdcbf94 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -280,8 +280,9 @@ 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 a preset or **Custom combination** to set an ASCII key, navigation key,
- or F1-F12 with Ctrl, Alt, and Shift, plus a label. Edit or clear individual slots;
+ Choose **Preset** or **Custom keyboard**. The US keyboard lets you toggle Ctrl,
+ Alt, and Shift and pick one letter, number, or basic key; expand **More keys**
+ for symbols, arrows, and F1-F12. 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
diff --git a/web/src/components/ConfigurationDialog.tsx b/web/src/components/ConfigurationDialog.tsx
index 8d3049f5..697f426b 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 1904f4be..d4d69ca9 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 {
@@ -220,7 +230,6 @@
}
.mobile-shortcut-slot-editor-fields input,
-.mobile-shortcut-slot-editor-fields select,
.mobile-shortcut-key-trigger {
width: 100%;
min-width: 0;
@@ -233,8 +242,7 @@
font: 12px / 1.2 inherit;
}
-.mobile-shortcut-slot-editor-fields input,
-.mobile-shortcut-slot-editor-fields select {
+.mobile-shortcut-slot-editor-fields input {
padding: 0 8px;
}
@@ -244,23 +252,100 @@
gap: 8px;
}
-.mobile-shortcut-modifiers {
+.mobile-shortcut-mode {
display: flex;
- flex-wrap: wrap;
- gap: 16px;
+ gap: 4px;
+ margin-bottom: 10px;
}
-.mobile-shortcut-modifiers label {
+.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;
- gap: 5px;
- text-transform: none;
+ 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-more-keys summary,
+.mobile-shortcut-help summary {
+ padding: 8px 0;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 12px;
}
-.mobile-shortcut-modifiers input {
- width: 16px;
- height: 16px;
- accent-color: var(--accent);
+.mobile-shortcut-help p {
+ margin: 0 0 4px;
}
.mobile-shortcut-help,
@@ -397,7 +482,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 00000000..37e3ef44
--- /dev/null
+++ b/web/src/components/MobileTerminalShortcutsDialog.test.ts
@@ -0,0 +1,149 @@
+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();
+ await click("Alt");
+ await click("Shift");
+ await click("Key Tab");
+ expect(button("Save shortcuts").disabled).toBe(true);
+ 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("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: "2",
+ ctrl: false,
+ alt: true,
+ shift: true,
+ });
+ expect(mobileTerminalShortcutBytes(savedSide[0]!.action)).toEqual([
+ 0x1b, 0x40,
+ ]);
+ } 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 49d4ccfa..2806c319 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.tsx
+++ b/web/src/components/MobileTerminalShortcutsDialog.tsx
@@ -3,6 +3,7 @@ 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,
@@ -32,12 +33,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import "./MobileTerminalShortcutsDialog.css";
const OPTION_GROUPS = ["Control", "Basic", "Navigation", "Modified"] as const;
-const CUSTOM_KEY_GROUPS = {
- Letters: Array.from("abcdefghijklmnopqrstuvwxyz"),
- Numbers: Array.from("0123456789"),
- Symbols: Array.from("`-=[]\\;',./~!@#$%^&*()_+{}|:\"<>?"),
- Special: MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS,
-};
+const US_KEY_ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
+const BASIC_KEYS = ["Escape", "Tab", "Backspace", "Enter", "Space"];
let nextShortcutId = 1;
type SelectedSlot =
@@ -88,42 +85,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(() =>
- typeof value === "string" ? value : "custom",
- );
+ const [activeValue, setActiveValue] = useState(value);
const currentOption = mobileTerminalShortcutOption(value);
const setSelectorOpen = (next: boolean) => {
setOpen(next);
setSearch("");
- if (next)
- setActiveValue(
- typeof valueRef.current === "string" ? valueRef.current : "custom",
- );
+ if (next) setActiveValue(value);
};
- useEffect(() => {
- if (openRequest === 0) return;
- setOpen(true);
- setSearch("");
- setActiveValue(
- typeof valueRef.current === "string" ? valueRef.current : "custom",
- );
- }, [openRequest]);
-
return (
@@ -163,24 +142,6 @@ function ShortcutKeySelect({
/>
No matching keys.
-
- {
- onChange(
- typeof value === "object"
- ? value
- : { key: "x", ctrl: true, alt: false, shift: false },
- );
- setSelectorOpen(false);
- }}
- >
- Custom combination
-
-
{OPTION_GROUPS.map((group) => (
{MOBILE_TERMINAL_SHORTCUT_OPTIONS.filter(
@@ -221,6 +182,113 @@ 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 (
+ onChange({ ...value, key })}
+ >
+ {key.length === 1
+ ? value.shift
+ ? shiftedKey
+ : key.toUpperCase()
+ : key
+ .replace("Arrow", "")
+ .replace("Escape", "Esc")
+ .replace("Backspace", "Bksp")
+ .replace("Page", "Pg")}
+
+ );
+ };
+
+ return (
+
+
+ US keyboard
+
+ {mobileTerminalShortcutOption(value)?.label}
+
+
+
+ {(["ctrl", "alt", "shift"] as const).map((modifier) => (
+ onChange({ ...value, [modifier]: !value[modifier] })}
+ >
+ {modifier === "ctrl"
+ ? "Ctrl"
+ : modifier === "alt"
+ ? "Alt"
+ : "Shift"}
+
+ ))}
+
+
+ {US_KEY_ROWS.map((row) => (
+
+ {Array.from(row, keyButton)}
+
+ ))}
+
+ {BASIC_KEYS.map(keyButton)}
+
+
+
+ More keys
+
+ {Array.from("`-=[]\\;',./", keyButton)}
+
+
+ {MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS.filter(
+ (key) => !BASIC_KEYS.includes(key),
+ ).map(keyButton)}
+
+
+
+ );
+}
+
export function MobileTerminalShortcutsDialog({
open,
rows,
@@ -250,14 +318,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;
@@ -289,7 +355,6 @@ export function MobileTerminalShortcutsDialog({
return next;
});
setSelectedSlot({ area: "panel", rowIndex, slotIndex });
- setKeySelectorOpenRequest((request) => request + 1);
};
const selectSideSlot = (slotIndex: number) => {
@@ -300,7 +365,6 @@ export function MobileTerminalShortcutsDialog({
return next;
});
setSelectedSlot({ area: "side", slotIndex });
- setKeySelectorOpenRequest((request) => request + 1);
};
const updateSelectedShortcut = (
@@ -388,10 +452,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.
@@ -517,7 +578,6 @@ export function MobileTerminalShortcutsDialog({
className={`mobile-shortcut-slot-editor ${
selectedShortcut ? "is-active" : ""
}`}
- aria-live="polite"
>
{selectedShortcut && selectedSlot ? (
<>
@@ -539,6 +599,36 @@ export function MobileTerminalShortcutsDialog({
Clear slot
+
+ {
+ if (customAction) updateSelectedAction("escape");
+ }}
+ >
+ Preset
+
+ {
+ if (!customAction)
+ updateSelectedAction({
+ key: "x",
+ ctrl: true,
+ alt: false,
+ shift: false,
+ });
+ }}
+ >
+ Custom keyboard
+
+
Label
@@ -558,71 +648,25 @@ export function MobileTerminalShortcutsDialog({
}
/>
-
- Key
-
+ Key
+
+
+ ) : null}
+ {customAction ? (
+
-
- {customAction ? (
-
-
- Custom key
- {
- const key = event.target.value;
- updateSelectedAction({ ...customAction, key });
- }}
- >
- {Object.entries(CUSTOM_KEY_GROUPS).map(
- ([group, keys]) => (
-
- {keys.map((key) => (
-
- {key}
-
- ))}
-
- ),
- )}
-
-
-
- {(["ctrl", "alt", "shift"] as const).map((modifier) => (
-
- {
- const checked = event.target.checked;
- updateSelectedAction({
- ...customAction,
- [modifier]: checked,
- });
- }}
- />
- {modifier === "ctrl"
- ? "Ctrl"
- : modifier === "alt"
- ? "Alt"
- : "Shift"}
-
- ))}
-
-
) : null}
>
@@ -636,21 +680,23 @@ export function MobileTerminalShortcutsDialog({
{hasInvalidCombination ? (
- A custom combination is unsupported. Ctrl/Alt+Tab,
- Ctrl/Shift+Escape, Shift+Backspace, and Ctrl with some symbols have
- no supported encoding. Change the key or modifiers before saving.
+ A custom combination is unsupported. Change its key or modifiers
+ before saving.
) : null}
-
- 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. Cmd/Meta, text macros, and multi-step sequences are
- not supported.
-
+
+ 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. Cmd/Meta, text macros, and
+ multi-step sequences are not supported.
+
+
Date: Wed, 30 Sep 2026 22:59:02 +0800
Subject: [PATCH 5/7] Show symbol keys in the mobile shortcut keyboard
---
FEATURES.md | 4 ++--
.../MobileTerminalShortcutsDialog.test.ts | 12 ++++++++++--
.../components/MobileTerminalShortcutsDialog.tsx | 16 ++++++++--------
3 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/FEATURES.md b/FEATURES.md
index 1bdcbf94..3532298a 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -281,8 +281,8 @@ separate for desktop/mobile. Jump from a diff to its file preview.
**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, or basic key; expand **More keys**
- for symbols, arrows, and F1-F12. Set a label and edit or clear individual slots;
+ Alt, and Shift and pick one letter, number, symbol, or basic key; expand **More keys**
+ for arrows and F1-F12. 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
diff --git a/web/src/components/MobileTerminalShortcutsDialog.test.ts b/web/src/components/MobileTerminalShortcutsDialog.test.ts
index 37e3ef44..a06fc0e3 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.test.ts
+++ b/web/src/components/MobileTerminalShortcutsDialog.test.ts
@@ -99,8 +99,14 @@ if (process.env.ROAMGATE_SHORTCUT_PICKER_DOM_TEST !== "1") {
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();
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);
await click("Key Y");
@@ -117,6 +123,8 @@ if (process.env.ROAMGATE_SHORTCUT_PICKER_DOM_TEST !== "1") {
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",
@@ -129,13 +137,13 @@ if (process.env.ROAMGATE_SHORTCUT_PICKER_DOM_TEST !== "1") {
0x1b, 0x19,
]);
expect(savedSide[0]?.action).toEqual({
- key: "2",
+ key: "[",
ctrl: false,
alt: true,
shift: true,
});
expect(mobileTerminalShortcutBytes(savedSide[0]!.action)).toEqual([
- 0x1b, 0x40,
+ 0x1b, 0x7b,
]);
} finally {
await act(async () => root.unmount());
diff --git a/web/src/components/MobileTerminalShortcutsDialog.tsx b/web/src/components/MobileTerminalShortcutsDialog.tsx
index 2806c319..aa59f9c1 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.tsx
+++ b/web/src/components/MobileTerminalShortcutsDialog.tsx
@@ -33,7 +33,14 @@ 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 US_KEY_ROWS = [
+ "1234567890",
+ "qwertyuiop",
+ "asdfghjkl",
+ "zxcvbnm",
+ "`-=[]\\",
+ ";',./",
+];
const BASIC_KEYS = ["Escape", "Tab", "Backspace", "Enter", "Space"];
let nextShortcutId = 1;
@@ -268,13 +275,6 @@ function CustomKeyPicker({
More keys
-
- {Array.from("`-=[]\\;',./", keyButton)}
-
Date: Wed, 30 Sep 2026 23:08:55 +0800
Subject: [PATCH 6/7] Group function keys and identify unsupported shortcuts
---
FEATURES.md | 5 +-
.../MobileTerminalShortcutsDialog.css | 7 +-
.../MobileTerminalShortcutsDialog.test.ts | 27 +++++++
.../MobileTerminalShortcutsDialog.tsx | 73 +++++++++++++------
4 files changed, 87 insertions(+), 25 deletions(-)
diff --git a/FEATURES.md b/FEATURES.md
index 3532298a..5ff5beeb 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -282,12 +282,13 @@ separate for desktop/mobile. Jump from a diff to its file preview.
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 arrows and F1-F12. Set a label and edit or clear individual slots;
+ 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 block saving. Buttons bypass browser keyboard
+ 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
diff --git a/web/src/components/MobileTerminalShortcutsDialog.css b/web/src/components/MobileTerminalShortcutsDialog.css
index d4d69ca9..3b4dcc4c 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.css
+++ b/web/src/components/MobileTerminalShortcutsDialog.css
@@ -336,6 +336,10 @@ html:has(.mobile-shortcuts-modal) {
margin-top: 6px;
}
+.mobile-shortcut-key-group {
+ margin-top: 8px;
+}
+
.mobile-shortcut-more-keys summary,
.mobile-shortcut-help summary {
padding: 8px 0;
@@ -344,7 +348,8 @@ html:has(.mobile-shortcuts-modal) {
font-size: 12px;
}
-.mobile-shortcut-help p {
+.mobile-shortcut-help p,
+.mobile-shortcut-error p {
margin: 0 0 4px;
}
diff --git a/web/src/components/MobileTerminalShortcutsDialog.test.ts b/web/src/components/MobileTerminalShortcutsDialog.test.ts
index a06fc0e3..b3171b21 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.test.ts
+++ b/web/src/components/MobileTerminalShortcutsDialog.test.ts
@@ -103,12 +103,39 @@ if (process.env.ROAMGATE_SHORTCUT_PICKER_DOM_TEST !== "1") {
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");
diff --git a/web/src/components/MobileTerminalShortcutsDialog.tsx b/web/src/components/MobileTerminalShortcutsDialog.tsx
index aa59f9c1..805635f8 100644
--- a/web/src/components/MobileTerminalShortcutsDialog.tsx
+++ b/web/src/components/MobileTerminalShortcutsDialog.tsx
@@ -42,6 +42,14 @@ const US_KEY_ROWS = [
";',./",
];
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 =
@@ -275,15 +283,18 @@ function CustomKeyPicker({
More keys
-
- {MOBILE_TERMINAL_CUSTOM_SPECIAL_KEYS.filter(
- (key) => !BASIC_KEYS.includes(key),
- ).map(keyButton)}
-
+ {Object.entries(EXTRA_KEY_GROUPS).map(([group, keys]) => (
+
+
{group}
+
+ {keys.map(keyButton)}
+
+
+ ))}
);
@@ -431,11 +442,23 @@ export function MobileTerminalShortcutsDialog({
selectedShortcut && typeof selectedShortcut.action === "object"
? selectedShortcut.action
: null;
- const hasInvalidCombination = [...draft.flat(), ...sideDraft].some(
- (shortcut) =>
- shortcut &&
- typeof shortcut.action === "object" &&
- !mobileTerminalKeyCombinationBytes(shortcut.action).length,
+ 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 (
@@ -678,11 +701,15 @@ export function MobileTerminalShortcutsDialog({
)}
- {hasInvalidCombination ? (
-
- A custom combination is unsupported. Change its key or modifiers
- before saving.
-
+ {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
@@ -693,8 +720,10 @@ export function MobileTerminalShortcutsDialog({
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. Cmd/Meta, text macros, and
- multi-step sequences are not supported.
+ 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.
@@ -717,7 +746,7 @@ export function MobileTerminalShortcutsDialog({
0}
onClick={() => {
onChange(normalizeMobileTerminalShortcutRows(draft));
onSideChange(normalizeMobileTerminalSideShortcuts(sideDraft));
From f1ce517b84331a8633c15e57fa8efafee7ea2c67 Mon Sep 17 00:00:00 2001
From: Wangshuyi
Date: Wed, 30 Sep 2026 23:24:01 +0800
Subject: [PATCH 7/7] Hide the mobile configuration footer
---
web/src/components/ConfigurationDialog.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/web/src/components/ConfigurationDialog.css b/web/src/components/ConfigurationDialog.css
index 853e17b9..a689c01b 100644
--- a/web/src/components/ConfigurationDialog.css
+++ b/web/src/components/ConfigurationDialog.css
@@ -165,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;
}