-
-
+ {showMobileKeys && visibleMobileSideShortcuts.length > 0 ? (
+
+ {visibleMobileSideShortcuts.map((shortcut) => {
+ const option = mobileTerminalShortcutOption(shortcut.action);
+ return (
+
+ );
+ })}
) : null}
diff --git a/web/src/components/overlayScrollbar.test.ts b/web/src/components/overlayScrollbar.test.ts
index 699d8ab..fa4960d 100644
--- a/web/src/components/overlayScrollbar.test.ts
+++ b/web/src/components/overlayScrollbar.test.ts
@@ -1,5 +1,37 @@
import { describe, expect, test } from "bun:test";
-import { calculateOverlayThumb } from "./overlayScrollbar";
+import {
+ calculateOverlayThumb,
+ overlayScrollbarExcludedElement,
+} from "./overlayScrollbar";
+
+describe("overlay scrollbar exclusions", () => {
+ test("excludes dialogs, popovers, menus, and mobile shortcut panels", () => {
+ for (const match of [
+ ".modal-backdrop",
+ ".popover-content",
+ ".config-dropdown",
+ ".context-menu",
+ ".pane-jump-popover",
+ ".agent-session-export-menu",
+ ".terminal-mobile-keys-panel",
+ "[role=dialog]",
+ "[role=menu]",
+ "[role=listbox]",
+ ]) {
+ expect(
+ overlayScrollbarExcludedElement({
+ closest: () => ({ match }) as unknown as Element,
+ }),
+ ).toBe(true);
+ }
+ });
+
+ test("keeps ordinary application scroll regions eligible", () => {
+ expect(
+ overlayScrollbarExcludedElement({ closest: () => null }),
+ ).toBe(false);
+ });
+});
describe("overlay scrollbar geometry", () => {
test("does not render when content fits", () => {
diff --git a/web/src/components/overlayScrollbar.ts b/web/src/components/overlayScrollbar.ts
index 5f52b3b..49a1f81 100644
--- a/web/src/components/overlayScrollbar.ts
+++ b/web/src/components/overlayScrollbar.ts
@@ -1,3 +1,22 @@
+export const OVERLAY_SCROLLBAR_EXCLUDED_SELECTOR = [
+ ".modal-backdrop",
+ ".popover-content",
+ ".config-dropdown",
+ ".context-menu",
+ ".pane-jump-popover",
+ ".agent-session-export-menu",
+ ".terminal-mobile-keys-panel",
+ "[role=dialog]",
+ "[role=menu]",
+ "[role=listbox]",
+].join(", ");
+
+export function overlayScrollbarExcludedElement(
+ element: Pick,
+): boolean {
+ return Boolean(element.closest(OVERLAY_SCROLLBAR_EXCLUDED_SELECTOR));
+}
+
export type OverlayThumbGeometry = {
start: number;
size: number;
diff --git a/web/src/mobileTerminalShortcutAction.test.ts b/web/src/mobileTerminalShortcutAction.test.ts
new file mode 100644
index 0000000..5d9dbd6
--- /dev/null
+++ b/web/src/mobileTerminalShortcutAction.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test";
+import { mobileTerminalShortcutExecution } from "./mobileTerminalShortcutAction";
+
+describe("mobile terminal shortcut execution", () => {
+ test("sends ordinary configured keys as terminal input", () => {
+ expect(mobileTerminalShortcutExecution("ctrl-c")).toEqual({
+ type: "input",
+ bytes: [0x03],
+ });
+ expect(mobileTerminalShortcutExecution("alt-up")).toEqual({
+ type: "input",
+ bytes: [0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x41],
+ });
+ });
+
+ test("routes page actions to full or half scrollback", () => {
+ expect(mobileTerminalShortcutExecution("page-up")).toEqual({
+ type: "scroll",
+ direction: "up",
+ amount: "full",
+ });
+ expect(mobileTerminalShortcutExecution("page-down")).toEqual({
+ type: "scroll",
+ direction: "down",
+ amount: "full",
+ });
+ expect(mobileTerminalShortcutExecution("alt-page-up")).toEqual({
+ type: "scroll",
+ direction: "up",
+ amount: "half",
+ });
+ expect(mobileTerminalShortcutExecution("alt-page-down")).toEqual({
+ type: "scroll",
+ direction: "down",
+ amount: "half",
+ });
+ });
+});
diff --git a/web/src/mobileTerminalShortcutAction.ts b/web/src/mobileTerminalShortcutAction.ts
new file mode 100644
index 0000000..3c07b3d
--- /dev/null
+++ b/web/src/mobileTerminalShortcutAction.ts
@@ -0,0 +1,25 @@
+import {
+ mobileTerminalShortcutBytes,
+ mobileTerminalShortcutScroll,
+ type MobileTerminalShortcutAction,
+} from "./mobileTerminalShortcuts";
+
+export type MobileTerminalShortcutExecution =
+ | {
+ type: "input";
+ bytes: number[];
+ }
+ | {
+ type: "scroll";
+ direction: "up" | "down";
+ amount: "full" | "half";
+ };
+
+export function mobileTerminalShortcutExecution(
+ action: MobileTerminalShortcutAction,
+): MobileTerminalShortcutExecution | null {
+ const scroll = mobileTerminalShortcutScroll(action);
+ if (scroll) return { type: "scroll", ...scroll };
+ const bytes = mobileTerminalShortcutBytes(action);
+ return bytes.length > 0 ? { type: "input", bytes } : null;
+}
diff --git a/web/src/mobileTerminalShortcuts.test.ts b/web/src/mobileTerminalShortcuts.test.ts
new file mode 100644
index 0000000..3e3c294
--- /dev/null
+++ b/web/src/mobileTerminalShortcuts.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, test } from "bun:test";
+import {
+ MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW,
+ defaultMobileTerminalShortcutRows,
+ defaultMobileTerminalSideShortcuts,
+ mobileTerminalShortcutBytes,
+ mobileTerminalShortcutCount,
+ mobileTerminalShortcutScroll,
+ normalizeMobileTerminalShortcutRows,
+ parseMobileTerminalShortcutRows,
+ parseMobileTerminalSideShortcuts,
+ serializeMobileTerminalShortcutRows,
+ serializeMobileTerminalSideShortcuts,
+} from "./mobileTerminalShortcuts";
+
+describe("mobile terminal shortcuts", () => {
+ test("uses the terminal controls across at most two aligned default rows", () => {
+ const rows = defaultMobileTerminalShortcutRows();
+
+ expect(rows).toHaveLength(2);
+ expect(MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW).toBe(8);
+ expect(
+ rows.map((row) =>
+ row.map((shortcut) => shortcut?.action ?? null),
+ ),
+ ).toEqual([
+ ["ctrl-c", "ctrl-d", "ctrl-r", "escape", "page-up", null, null, null],
+ ["tab", "enter", "alt-up", "page-down", null, null, null, null],
+ ]);
+ expect(rows.every((row) => row.length <= MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW)).toBe(true);
+ });
+
+ test("normalizes untrusted stored rows, labels, actions, and ids", () => {
+ const rows = normalizeMobileTerminalShortcutRows([
+ [
+ { id: "same", label: " Interrupt ", action: "ctrl-c" },
+ { id: "same", label: "😀😀😀😀😀😀😀😀😀😀😀", action: "enter" },
+ { id: "bad id", label: "Ignored", action: "not-a-key" },
+ ...Array.from({ length: 8 }, (_, index) => ({
+ id: `extra-${index}`,
+ label: "Esc",
+ action: "escape",
+ })),
+ ],
+ [{ id: "up", label: "", action: "arrow-up" }],
+ [{ id: "third", label: "Third", action: "tab" }],
+ ]);
+
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toHaveLength(MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW);
+ expect(rows[0][0]).toEqual({
+ id: "same",
+ label: "Interrupt",
+ action: "ctrl-c",
+ });
+ expect(rows[0][1]?.id).toBe("same-2");
+ expect(Array.from(rows[0][1]?.label ?? "")).toHaveLength(10);
+ expect(rows[1]).toEqual([
+ { id: "up", label: "Up", action: "arrow-up" },
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ });
+
+ test("migrates legacy compact rows past invalid entries", () => {
+ const rows = normalizeMobileTerminalShortcutRows([
+ [
+ { id: "first", label: "First", action: "ctrl-a" },
+ { id: "invalid", label: "Invalid", action: "unknown" },
+ { id: "second", label: "Second", action: "ctrl-b" },
+ ],
+ [],
+ ]);
+
+ expect(rows[0][0]?.id).toBe("first");
+ expect(rows[0][1]?.id).toBe("second");
+ expect(rows[0][2]).toBeNull();
+ });
+
+ test("preserves empty slots instead of compacting later buttons", () => {
+ const rows = normalizeMobileTerminalShortcutRows([
+ [null, null, { id: "third", label: "Home", action: "home" }],
+ [null, { id: "second", label: "End", action: "end" }],
+ ]);
+
+ expect(rows[0][0]).toBeNull();
+ expect(rows[0][2]?.action).toBe("home");
+ expect(rows[1][1]?.action).toBe("end");
+ expect(parseMobileTerminalShortcutRows(serializeMobileTerminalShortcutRows(rows))).toEqual(rows);
+ });
+
+ test("preserves four optional side shortcut slots", () => {
+ const shortcuts = parseMobileTerminalSideShortcuts(
+ JSON.stringify([
+ null,
+ { id: "side-two", label: " Half ", action: "alt-page-up" },
+ { id: "invalid", label: "No", action: "unknown" },
+ { id: "side-four", label: "End", action: "end" },
+ { id: "ignored", label: "Esc", action: "escape" },
+ ]),
+ );
+
+ expect(defaultMobileTerminalSideShortcuts()).toEqual([
+ null,
+ null,
+ null,
+ null,
+ ]);
+ expect(shortcuts).toEqual([
+ null,
+ { id: "side-two", label: "Half", action: "alt-page-up" },
+ null,
+ { id: "side-four", label: "End", action: "end" },
+ ]);
+ expect(
+ parseMobileTerminalSideShortcuts(
+ serializeMobileTerminalSideShortcuts(shortcuts),
+ ),
+ ).toEqual(shortcuts);
+ expect(parseMobileTerminalSideShortcuts("bad json")).toEqual([
+ null,
+ null,
+ null,
+ null,
+ ]);
+ });
+
+ test("allows users to clear all panel slots", () => {
+ const empty = normalizeMobileTerminalShortcutRows([[], []]);
+
+ expect(mobileTerminalShortcutCount(empty)).toBe(0);
+ expect(parseMobileTerminalShortcutRows(serializeMobileTerminalShortcutRows(empty))).toEqual(empty);
+ });
+
+ test("falls back safely for missing or malformed storage", () => {
+ const expected = defaultMobileTerminalShortcutRows();
+
+ expect(parseMobileTerminalShortcutRows(null)).toEqual(expected);
+ expect(parseMobileTerminalShortcutRows("not json")).toEqual(expected);
+
+ });
+
+ test("round-trips normalized rows without sharing mutable defaults", () => {
+ const first = defaultMobileTerminalShortcutRows();
+ first[0][0]!.label = "Changed";
+ expect(defaultMobileTerminalShortcutRows()[0][0]?.label).toBe("C-c");
+
+ const encoded = serializeMobileTerminalShortcutRows(first);
+ const parsed = parseMobileTerminalShortcutRows(encoded);
+ expect(parsed[0][0]?.label).toBe("Changed");
+ expect(mobileTerminalShortcutCount(parsed)).toBe(9);
+ });
+
+ test("encodes control, navigation, and modified keys", () => {
+ expect(mobileTerminalShortcutBytes("ctrl-c")).toEqual([0x03]);
+ expect(mobileTerminalShortcutBytes("page-up")).toEqual([]);
+ expect(mobileTerminalShortcutBytes("page-down")).toEqual([]);
+ expect(mobileTerminalShortcutBytes("alt-up")).toEqual([
+ 0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x41,
+ ]);
+ expect(mobileTerminalShortcutBytes("alt-page-up")).toEqual([]);
+ expect(mobileTerminalShortcutBytes("alt-page-down")).toEqual([]);
+ expect(mobileTerminalShortcutBytes("shift-enter")).toEqual([
+ 0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75,
+ ]);
+ });
+
+ test("routes page actions to scrollback instead of terminal input", () => {
+ expect(mobileTerminalShortcutScroll("page-up")).toEqual({
+ direction: "up",
+ amount: "full",
+ });
+ expect(mobileTerminalShortcutScroll("page-down")).toEqual({
+ direction: "down",
+ amount: "full",
+ });
+ expect(mobileTerminalShortcutScroll("alt-page-up")).toEqual({
+ direction: "up",
+ amount: "half",
+ });
+ expect(mobileTerminalShortcutScroll("alt-page-down")).toEqual({
+ direction: "down",
+ amount: "half",
+ });
+ expect(mobileTerminalShortcutScroll("arrow-up")).toBeNull();
+ });
+});
diff --git a/web/src/mobileTerminalShortcuts.ts b/web/src/mobileTerminalShortcuts.ts
new file mode 100644
index 0000000..58e95bf
--- /dev/null
+++ b/web/src/mobileTerminalShortcuts.ts
@@ -0,0 +1,300 @@
+export const MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY =
+ "mobileTerminalShortcuts.v2";
+export const LEGACY_MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY =
+ "mobileTerminalShortcuts.v1";
+export const MAX_MOBILE_TERMINAL_SHORTCUT_ROWS = 2;
+export const MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW = 8;
+export const MAX_MOBILE_TERMINAL_SHORTCUT_LABEL_LENGTH = 10;
+export const MOBILE_TERMINAL_SIDE_SHORTCUTS_STORAGE_KEY =
+ "mobileTerminalSideShortcuts.v1";
+export const MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS = 4;
+
+type MobileTerminalShortcutOptionDefinition = {
+ id: string;
+ label: string;
+ defaultButtonLabel: string;
+ group: "Control" | "Basic" | "Navigation" | "Modified";
+ bytes?: readonly number[];
+ scroll?: {
+ direction: "up" | "down";
+ amount: "full" | "half";
+ };
+};
+
+export const MOBILE_TERMINAL_SHORTCUT_OPTIONS = [
+ { id: "ctrl-a", label: "Ctrl+A", defaultButtonLabel: "C-a", group: "Control", bytes: [0x01] },
+ { id: "ctrl-b", label: "Ctrl+B", defaultButtonLabel: "C-b", group: "Control", bytes: [0x02] },
+ { id: "ctrl-c", label: "Ctrl+C", defaultButtonLabel: "C-c", group: "Control", bytes: [0x03] },
+ { id: "ctrl-d", label: "Ctrl+D", defaultButtonLabel: "C-d", group: "Control", bytes: [0x04] },
+ { id: "ctrl-e", label: "Ctrl+E", defaultButtonLabel: "C-e", group: "Control", bytes: [0x05] },
+ { id: "ctrl-f", label: "Ctrl+F", defaultButtonLabel: "C-f", group: "Control", bytes: [0x06] },
+ { id: "ctrl-k", label: "Ctrl+K", defaultButtonLabel: "C-k", group: "Control", bytes: [0x0b] },
+ { id: "ctrl-l", label: "Ctrl+L", defaultButtonLabel: "C-l", group: "Control", bytes: [0x0c] },
+ { id: "ctrl-n", label: "Ctrl+N", defaultButtonLabel: "C-n", group: "Control", bytes: [0x0e] },
+ { id: "ctrl-p", label: "Ctrl+P", defaultButtonLabel: "C-p", group: "Control", bytes: [0x10] },
+ { id: "ctrl-r", label: "Ctrl+R", defaultButtonLabel: "C-R", group: "Control", bytes: [0x12] },
+ { id: "ctrl-u", label: "Ctrl+U", defaultButtonLabel: "C-u", group: "Control", bytes: [0x15] },
+ { id: "ctrl-w", label: "Ctrl+W", defaultButtonLabel: "C-w", group: "Control", bytes: [0x17] },
+ { id: "ctrl-z", label: "Ctrl+Z", defaultButtonLabel: "C-z", group: "Control", bytes: [0x1a] },
+ { id: "escape", label: "Escape", defaultButtonLabel: "Esc", group: "Basic", bytes: [0x1b] },
+ { id: "tab", label: "Tab", defaultButtonLabel: "Tab", group: "Basic", bytes: [0x09] },
+ { id: "enter", label: "Enter", defaultButtonLabel: "Enter", group: "Basic", bytes: [0x0d] },
+ { id: "backspace", label: "Backspace", defaultButtonLabel: "Bksp", group: "Basic", bytes: [0x7f] },
+ { id: "delete", label: "Delete", defaultButtonLabel: "Del", group: "Basic", bytes: [0x1b, 0x5b, 0x33, 0x7e] },
+ { id: "arrow-up", label: "Arrow Up", defaultButtonLabel: "Up", group: "Navigation", bytes: [0x1b, 0x5b, 0x41] },
+ { id: "arrow-down", label: "Arrow Down", defaultButtonLabel: "Down", group: "Navigation", bytes: [0x1b, 0x5b, 0x42] },
+ { id: "arrow-right", label: "Arrow Right", defaultButtonLabel: "Right", group: "Navigation", bytes: [0x1b, 0x5b, 0x43] },
+ { id: "arrow-left", label: "Arrow Left", defaultButtonLabel: "Left", group: "Navigation", bytes: [0x1b, 0x5b, 0x44] },
+ { id: "home", label: "Home", defaultButtonLabel: "Home", group: "Navigation", bytes: [0x1b, 0x5b, 0x48] },
+ { id: "end", label: "End", defaultButtonLabel: "End", group: "Navigation", bytes: [0x1b, 0x5b, 0x46] },
+ { id: "page-up", label: "Page Up (scrollback)", defaultButtonLabel: "PgUp", group: "Navigation", scroll: { direction: "up", amount: "full" } },
+ { id: "page-down", label: "Page Down (scrollback)", defaultButtonLabel: "PgDn", group: "Navigation", scroll: { direction: "down", amount: "full" } },
+ { id: "alt-up", label: "Alt+Up", defaultButtonLabel: "A-Up", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x41] },
+ { id: "alt-down", label: "Alt+Down", defaultButtonLabel: "A-Down", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x42] },
+ { id: "alt-right", label: "Alt+Right", defaultButtonLabel: "A-Right", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x43] },
+ { id: "alt-left", label: "Alt+Left", defaultButtonLabel: "A-Left", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x3b, 0x33, 0x44] },
+ { id: "alt-page-up", label: "Alt+Page Up (half scrollback)", defaultButtonLabel: "A-PgUp", group: "Modified", scroll: { direction: "up", amount: "half" } },
+ { id: "alt-page-down", label: "Alt+Page Down (half scrollback)", defaultButtonLabel: "A-PgDn", group: "Modified", scroll: { direction: "down", amount: "half" } },
+ { id: "shift-enter", label: "Shift+Enter", defaultButtonLabel: "S-Enter", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75] },
+ { id: "alt-enter", label: "Alt+Enter", defaultButtonLabel: "A-Enter", group: "Modified", bytes: [0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x33, 0x75] },
+] as const satisfies readonly MobileTerminalShortcutOptionDefinition[];
+
+export type MobileTerminalShortcutAction =
+ (typeof MOBILE_TERMINAL_SHORTCUT_OPTIONS)[number]["id"];
+
+export type MobileTerminalShortcut = {
+ id: string;
+ label: string;
+ action: MobileTerminalShortcutAction;
+};
+
+export type MobileTerminalShortcutSlot = MobileTerminalShortcut | null;
+
+export type MobileTerminalShortcutRows = [
+ MobileTerminalShortcutSlot[],
+ MobileTerminalShortcutSlot[],
+];
+
+export type MobileTerminalSideShortcuts = MobileTerminalShortcutSlot[];
+
+const optionById = new Map<
+ MobileTerminalShortcutAction,
+ MobileTerminalShortcutOptionDefinition
+>(MOBILE_TERMINAL_SHORTCUT_OPTIONS.map((option) => [option.id, option]));
+
+const defaultRows: MobileTerminalShortcutRows = [
+ [
+ { id: "default-ctrl-c", label: "C-c", action: "ctrl-c" },
+ { id: "default-ctrl-d", label: "C-d", action: "ctrl-d" },
+ { id: "default-ctrl-r", label: "C-R", action: "ctrl-r" },
+ { id: "default-escape", label: "Esc", action: "escape" },
+ { id: "default-page-up", label: "PgUp", action: "page-up" },
+ null,
+ null,
+ null,
+ ],
+ [
+ { id: "default-tab", label: "Tab", action: "tab" },
+ { id: "default-enter", label: "Enter", action: "enter" },
+ { id: "default-alt-up", label: "A-Up", action: "alt-up" },
+ { id: "default-page-down", label: "PgDn", action: "page-down" },
+ null,
+ null,
+ null,
+ null,
+ ],
+];
+
+export function defaultMobileTerminalShortcutRows(): MobileTerminalShortcutRows {
+ return defaultRows.map((row) =>
+ row.map((shortcut) => (shortcut ? { ...shortcut } : null)),
+ ) as MobileTerminalShortcutRows;
+}
+
+export function defaultMobileTerminalSideShortcuts(): MobileTerminalSideShortcuts {
+ return Array(
+ MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS,
+ ).fill(null);
+}
+
+export function mobileTerminalShortcutOption(
+ action: MobileTerminalShortcutAction,
+) {
+ return optionById.get(action) ?? null;
+}
+
+export function mobileTerminalShortcutBytes(
+ action: MobileTerminalShortcutAction,
+): number[] {
+ return [...(optionById.get(action)?.bytes ?? [])];
+}
+
+export function mobileTerminalShortcutScroll(
+ action: MobileTerminalShortcutAction,
+): { direction: "up" | "down"; amount: "full" | "half" } | null {
+ const scroll = optionById.get(action)?.scroll;
+ return scroll ? { ...scroll } : null;
+}
+
+function clipLabel(value: string): string {
+ return Array.from(value.trim())
+ .slice(0, MAX_MOBILE_TERMINAL_SHORTCUT_LABEL_LENGTH)
+ .join("");
+}
+
+function normalizedId(
+ value: unknown,
+ rowIndex: number,
+ itemIndex: number,
+ usedIds: Set,
+): string {
+ const requested =
+ typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value)
+ ? value
+ : `shortcut-${rowIndex + 1}-${itemIndex + 1}`;
+ let id = requested;
+ let suffix = 2;
+ while (usedIds.has(id)) {
+ id = `${requested}-${suffix}`;
+ suffix += 1;
+ }
+ usedIds.add(id);
+ return id;
+}
+
+export function normalizeMobileTerminalShortcutRows(
+ value: unknown,
+): MobileTerminalShortcutRows {
+ if (!Array.isArray(value)) return defaultMobileTerminalShortcutRows();
+ const rows: MobileTerminalShortcutRows = [
+ Array(
+ MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW,
+ ).fill(null),
+ Array(
+ MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW,
+ ).fill(null),
+ ];
+ const usedIds = new Set();
+
+ for (
+ let rowIndex = 0;
+ rowIndex < Math.min(value.length, MAX_MOBILE_TERMINAL_SHORTCUT_ROWS);
+ rowIndex += 1
+ ) {
+ const sourceRow = value[rowIndex];
+ if (!Array.isArray(sourceRow)) continue;
+ let legacySlotIndex = 0;
+ const hasExplicitEmptySlots = sourceRow.some(
+ (candidate) => candidate === null,
+ );
+ for (
+ let sourceIndex = 0;
+ sourceIndex <
+ Math.min(sourceRow.length, MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW);
+ sourceIndex += 1
+ ) {
+ const candidate = sourceRow[sourceIndex];
+ if (!candidate || typeof candidate !== "object") continue;
+ const raw = candidate as Record;
+ if (
+ typeof raw.action !== "string" ||
+ !optionById.has(raw.action as MobileTerminalShortcutAction)
+ ) {
+ continue;
+ }
+ const action = raw.action as MobileTerminalShortcutAction;
+ const option = optionById.get(action)!;
+ const label =
+ typeof raw.label === "string" && clipLabel(raw.label)
+ ? clipLabel(raw.label)
+ : option.defaultButtonLabel;
+ const slotIndex = hasExplicitEmptySlots ? sourceIndex : legacySlotIndex;
+ legacySlotIndex += 1;
+ rows[rowIndex][slotIndex] = {
+ id: normalizedId(raw.id, rowIndex, slotIndex, usedIds),
+ label,
+ action,
+ };
+ }
+ }
+
+ return rows;
+}
+
+export function normalizeMobileTerminalSideShortcuts(
+ value: unknown,
+): MobileTerminalSideShortcuts {
+ const shortcuts = defaultMobileTerminalSideShortcuts();
+ if (!Array.isArray(value)) return shortcuts;
+ const usedIds = new Set();
+ for (
+ let slotIndex = 0;
+ slotIndex <
+ Math.min(value.length, MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS);
+ slotIndex += 1
+ ) {
+ const candidate = value[slotIndex];
+ if (!candidate || typeof candidate !== "object") continue;
+ const raw = candidate as Record;
+ if (
+ typeof raw.action !== "string" ||
+ !optionById.has(raw.action as MobileTerminalShortcutAction)
+ ) {
+ continue;
+ }
+ const action = raw.action as MobileTerminalShortcutAction;
+ const option = optionById.get(action)!;
+ shortcuts[slotIndex] = {
+ id: normalizedId(raw.id, 2, slotIndex, usedIds),
+ label:
+ typeof raw.label === "string" && clipLabel(raw.label)
+ ? clipLabel(raw.label)
+ : option.defaultButtonLabel,
+ action,
+ };
+ }
+ return shortcuts;
+}
+
+export function parseMobileTerminalSideShortcuts(
+ raw: string | null,
+): MobileTerminalSideShortcuts {
+ if (!raw) return defaultMobileTerminalSideShortcuts();
+ try {
+ return normalizeMobileTerminalSideShortcuts(JSON.parse(raw));
+ } catch {
+ return defaultMobileTerminalSideShortcuts();
+ }
+}
+
+export function serializeMobileTerminalSideShortcuts(
+ shortcuts: MobileTerminalSideShortcuts,
+): string {
+ return JSON.stringify(normalizeMobileTerminalSideShortcuts(shortcuts));
+}
+
+export function parseMobileTerminalShortcutRows(
+ raw: string | null,
+): MobileTerminalShortcutRows {
+ if (!raw) return defaultMobileTerminalShortcutRows();
+ try {
+ return normalizeMobileTerminalShortcutRows(JSON.parse(raw));
+ } catch {
+ return defaultMobileTerminalShortcutRows();
+ }
+}
+
+export function serializeMobileTerminalShortcutRows(
+ rows: MobileTerminalShortcutRows,
+): string {
+ return JSON.stringify(normalizeMobileTerminalShortcutRows(rows));
+}
+
+export function mobileTerminalShortcutCount(
+ rows: MobileTerminalShortcutRows,
+): number {
+ return rows.reduce(
+ (total, row) => total + row.filter((shortcut) => shortcut !== null).length,
+ 0,
+ );
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index 5124864..a30286f 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -1802,6 +1802,307 @@ textarea:focus {
flex-direction: column;
overflow: hidden;
}
+.mobile-shortcuts-modal {
+ width: min(760px, 100%);
+ max-height: min(760px, calc(100dvh - 36px));
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+.mobile-shortcuts-modal .modal-head {
+ align-items: flex-start;
+ margin: 0;
+}
+.mobile-shortcuts-modal .modal-head p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+.mobile-shortcut-slot-board {
+ flex: 0 0 auto;
+ min-height: 0;
+ display: grid;
+ gap: 8px;
+ overflow: auto;
+ padding: 8px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: var(--panel-2);
+}
+.mobile-shortcut-slot-row {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 62px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+}
+.mobile-shortcut-slot-row-label {
+ display: grid;
+ gap: 2px;
+}
+.mobile-shortcut-slot-row-label strong {
+ color: var(--text-strong);
+ font-size: 12px;
+}
+.mobile-shortcut-slot-row-label span {
+ color: var(--muted);
+ font-size: 10px;
+}
+.mobile-shortcut-slot-grid {
+ min-width: max-content;
+ display: grid;
+ grid-template-columns: repeat(8, 72px);
+ gap: 3px;
+}
+.mobile-shortcut-slot {
+ min-width: 0;
+ height: 52px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ padding: 4px;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--border) 76%, var(--text) 24%);
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--panel) 86%, var(--panel-2));
+ color: var(--text-strong);
+}
+.mobile-shortcut-slot strong,
+.mobile-shortcut-slot span {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-shortcut-slot strong {
+ font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+.mobile-shortcut-slot span {
+ color: var(--muted);
+ font-size: 9px;
+}
+.mobile-shortcut-slot.is-empty {
+ border-style: dashed;
+ background: color-mix(in srgb, var(--input-bg) 45%, transparent);
+ color: var(--muted);
+}
+.mobile-shortcut-slot:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
+ background: color-mix(in srgb, var(--accent-soft) 50%, var(--panel));
+}
+.mobile-shortcut-slot.is-selected {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-soft) 72%, transparent);
+}
+.mobile-shortcut-side-board {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: var(--panel-2);
+}
+.mobile-shortcut-side-head {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.mobile-shortcut-side-head > div {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+.mobile-shortcut-side-head strong {
+ color: var(--text-strong);
+ font-size: 12px;
+}
+.mobile-shortcut-side-head span {
+ color: var(--muted);
+ font-size: 10px;
+}
+.mobile-shortcut-side-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 72px);
+ gap: 3px;
+}
+.mobile-shortcut-side-slot {
+ height: 46px;
+}
+.mobile-shortcut-slot-editor {
+ flex: 0 0 auto;
+ min-height: 96px;
+ padding: 10px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: var(--panel-2);
+}
+.mobile-shortcut-slot-editor-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 9px;
+}
+.mobile-shortcut-slot-editor-head > div {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+.mobile-shortcut-slot-editor-head strong {
+ color: var(--text-strong);
+ font-size: 12px;
+}
+.mobile-shortcut-slot-editor-head span {
+ color: var(--muted);
+ font-size: 10px;
+}
+.mobile-shortcut-slot-editor-head button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--danger-text);
+}
+.mobile-shortcut-slot-editor-fields {
+ display: grid;
+ grid-template-columns: minmax(100px, 0.8fr) minmax(150px, 1.2fr);
+ gap: 8px;
+}
+.mobile-shortcut-slot-editor-fields label,
+.mobile-shortcut-field {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+.mobile-shortcut-slot-editor-fields input,
+.mobile-shortcut-key-trigger {
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ box-sizing: border-box;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--input-bg);
+ color: var(--text-strong);
+ font: 12px/1.2 inherit;
+}
+.mobile-shortcut-slot-editor-fields input {
+ padding: 0 8px;
+}
+.mobile-shortcut-slot-editor-empty {
+ min-height: 74px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+ text-align: center;
+}
+.mobile-shortcut-key-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ padding: 0 7px 0 8px;
+ text-align: left;
+ text-transform: none;
+}
+.mobile-shortcut-key-trigger > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-shortcut-key-trigger > svg {
+ flex: 0 0 auto;
+ color: var(--muted);
+}
+.mobile-shortcut-key-trigger:hover:not(:disabled),
+.mobile-shortcut-key-trigger.is-open,
+.mobile-shortcut-key-trigger:focus-visible {
+ border-color: var(--accent);
+}
+.mobile-shortcut-key-trigger.is-open {
+ box-shadow: 0 0 0 2px var(--accent-soft);
+}
+.mobile-shortcut-key-popover {
+ z-index: 2100;
+ width: min(280px, calc(100vw - 24px));
+ max-height: min(390px, var(--radix-popover-content-available-height));
+ overflow: hidden;
+ padding: 0;
+ border-radius: 10px;
+}
+.mobile-shortcut-key-command .command-input {
+ height: 38px;
+ padding: 0 11px;
+ font-size: 12px;
+}
+.mobile-shortcut-key-command .command-list {
+ max-height: min(330px, calc(100dvh - 108px));
+ padding: 5px;
+}
+.mobile-shortcut-key-command .command-group {
+ padding: 2px 0;
+}
+.mobile-shortcut-key-command .command-group [cmdk-group-heading] {
+ padding: 6px 7px 4px;
+ font-size: 10px;
+}
+.mobile-shortcut-key-option {
+ grid-template-columns: minmax(0, 1fr) auto 14px;
+ gap: 8px;
+ min-height: 34px;
+ padding: 5px 7px;
+ font-size: 12px;
+ text-transform: none;
+}
+.mobile-shortcut-key-option > span {
+ overflow: hidden;
+ color: var(--text-strong);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-shortcut-key-option kbd {
+ min-width: 34px;
+ padding: 3px 5px;
+ border: 1px solid var(--border-soft);
+ border-radius: 5px;
+ background: var(--panel-2);
+ color: var(--muted);
+ font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ text-align: center;
+}
+.mobile-shortcut-key-option > svg {
+ color: var(--accent);
+ opacity: 0;
+}
+.mobile-shortcut-key-option[data-current="true"] > svg {
+ opacity: 1;
+}
+.modal-actions.mobile-shortcuts-actions {
+ display: grid;
+ grid-template-columns: auto 1fr auto auto;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+}
.file-explorer-modal {
width: min(900px, 100%);
}
@@ -5617,10 +5918,8 @@ textarea:focus {
text-align: center;
padding: 24px;
}
-.terminal-mobile-keys {
- display: none;
-}
-.terminal-page-scroll {
+.terminal-mobile-keys,
+.terminal-mobile-side-shortcuts {
display: none;
}
.mobile-controls-toggle {
@@ -5633,6 +5932,50 @@ textarea:focus {
}
@media (max-width: 768px) {
+ .mobile-shortcuts-modal {
+ max-height: calc(
+ 100dvh - 24px - env(safe-area-inset-top, 0px) -
+ env(safe-area-inset-bottom, 0px)
+ );
+ gap: 10px;
+ padding: 12px;
+ }
+ .mobile-shortcut-slot-board {
+ overflow-x: auto;
+ }
+ .mobile-shortcut-side-board {
+ grid-template-columns: minmax(0, 1fr);
+ overflow-x: auto;
+ }
+ .mobile-shortcut-side-grid {
+ min-width: max-content;
+ grid-template-columns: repeat(4, 58px);
+ }
+ .mobile-shortcut-slot-row {
+ grid-template-columns: 48px minmax(0, 1fr);
+ gap: 5px;
+ }
+ .mobile-shortcut-slot-grid {
+ grid-template-columns: repeat(8, 58px);
+ }
+ .mobile-shortcut-slot {
+ height: 48px;
+ }
+ .mobile-shortcut-slot-editor-fields {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .modal-actions.mobile-shortcuts-actions {
+ grid-template-columns: 1fr 1fr;
+ }
+ .mobile-shortcuts-actions > span {
+ display: none;
+ }
+ .mobile-shortcuts-actions button {
+ min-height: 34px;
+ }
+ .mobile-shortcuts-actions button:first-child {
+ grid-column: 1 / -1;
+ }
.worktree-lifecycle-modal {
width: 100%;
height: calc(
@@ -6284,17 +6627,37 @@ textarea:focus {
background: var(--accent-soft);
}
.terminal-mobile-keys-panel {
- display: inline-flex;
- gap: 4px;
- padding: 4px;
+ max-width: calc(100vw - 62px - env(safe-area-inset-right, 0px));
+ padding: 3px;
+ overflow-x: auto;
+ direction: rtl;
+ text-align: right;
border-radius: 12px;
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.9);
- transform-origin: right center;
+ transform-origin: right top;
transition:
opacity 140ms ease,
transform 160ms ease;
+ scrollbar-width: none;
+ }
+ .terminal-mobile-keys-panel::-webkit-scrollbar {
+ display: none;
+ }
+ .terminal-mobile-keys-grid {
+ width: max-content;
+ display: grid;
+ grid-template-columns: repeat(var(--mobile-shortcut-columns), 48px);
+ direction: ltr;
+ gap: 2px;
+ }
+ .terminal-mobile-keys-row {
+ width: 100%;
+ grid-column: 1 / -1;
+ display: flex;
+ justify-content: flex-end;
+ gap: 2px;
}
.terminal-mobile-keys.is-open .terminal-mobile-keys-panel {
opacity: 1;
@@ -6302,48 +6665,60 @@ textarea:focus {
transform: translateX(0) scale(1);
}
.terminal-mobile-keys-panel button {
- min-width: 34px;
+ width: 48px;
+ min-width: 0;
height: 28px;
+ flex: 0 0 48px;
display: inline-flex;
align-items: center;
justify-content: center;
- padding: 0 7px;
- border: none;
- border-radius: 8px;
- background: transparent;
+ padding: 0 4px;
+ border: 1px solid color-mix(in srgb, var(--border) 72%, var(--text) 28%);
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--panel-2) 76%, transparent);
color: var(--text-strong);
+ overflow: hidden;
font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
+ .terminal-mobile-keys-panel button:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
+ background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel-2));
+ }
.terminal-mobile-keys-panel button:active {
+ border-color: var(--accent);
background: var(--accent-soft);
}
- .terminal-page-scroll {
+ .terminal-mobile-side-shortcuts {
position: absolute;
top: 50%;
right: calc(8px + env(safe-area-inset-right, 0px));
z-index: 5;
display: grid;
- gap: 6px;
+ gap: 3px;
transform: translateY(-50%);
pointer-events: auto;
}
- .terminal-page-scroll button {
- width: 38px;
- height: 34px;
- padding: 0;
- border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--panel) 66%, transparent);
- color: var(--text-strong);
+ .terminal-mobile-side-shortcuts button {
+ width: 42px;
+ height: 32px;
+ padding: 0 4px;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--border) 72%, var(--text) 28%);
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--panel) 70%, transparent);
box-shadow: var(--shadow-lg);
- font-size: 12px;
- font-weight: 700;
+ color: var(--text-strong);
+ font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
@@ -6352,9 +6727,9 @@ textarea:focus {
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
- .terminal-page-scroll button:active {
- background: var(--accent-soft);
+ .terminal-mobile-side-shortcuts button:active {
border-color: var(--accent);
+ background: var(--accent-soft);
}
.terminal-view {
border-right: none;