diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d4baa..a4b528f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +### Added + +- Customize the mobile terminal shortcut panel through a direct 2-by-8 slot editor: + select any compact, bordered grid position to add or edit its label and key + action with a searchable, theme-aware picker. Empty positions are preserved + in the editor but compacted out of the runtime shortcut panel, and Page Up and + Page Down are included by default instead of separate fixed scroll buttons. + Up to four optional right-side buttons can also be configured for the original + Up/Down position. + +### Changed + +- Route configurable Page Up/Down actions through terminal scrollback (one page, + or half a page with Alt) instead of sending escape sequences that shells can + interpret as input-history navigation. +- Hide the application overlay scrollbar inside dialogs, popovers, menus, and + the mobile shortcut panel while retaining touch, wheel, and trackpad scrolling. + ## 0.3.2 - 2026-08-10 ### Changed diff --git a/USAGE.md b/USAGE.md index f911a00..456dfb0 100644 --- a/USAGE.md +++ b/USAGE.md @@ -358,15 +358,26 @@ launchctl bootout "gui/$(id -u)/dev.herdr.herdr-gui" ## Terminal -terminal 区域支持鼠标滚轮和触摸滑动。移动端会显示快捷键栏: - -- `Ctrl+C` -- `Ctrl+D` -- `Esc` -- `Tab` -- `Enter` - -如果移动端没有系统 Nerd Font,herdr-gui 会加载内置的 glyph-only Nerd Font 子集,用来显示常见图标字符。 +terminal 区域支持鼠标滚轮和触摸滑动。移动端默认显示两行对齐的快捷键,包含 +`Ctrl+C`、`Ctrl+D`、`Ctrl+R`、`Esc`、`Tab`、`Enter`、`Alt+Up`、 +`Page Up` 和 `Page Down`。 + +打开顶部 `Menu`,选择 `Mobile terminal shortcuts` 可以自定义快捷键阵列: + +- 固定显示 `2×8` 个可配置槽位;点击已有按钮可以修改,点击空的 `+` + 槽位可以直接在该位置添加按钮。 +- 可以修改按钮文字和动作;`Page Up` / `Page Down` 浏览一整页终端历史, + `Alt+Page Up` / `Alt+Page Down` 浏览半页终端历史,其他按键会发送给 + shell 或 TUI。 +- 清空槽位不会挤压编辑器里的其他按钮,所选位置会原样保存;终端快捷键 + 悬浮框会自动忽略空槽位并紧凑显示非空按钮。 +- 两行使用相同宽度的网格列;内容较多时可以横向滚动。 +- 还可以配置最多四个纵向侧边按钮,显示在原 `Up` / `Dn` 所在的终端 + 右侧位置;默认均为空,空槽位不会显示。 +- 两组配置都保存在当前浏览器中,不会修改 Herdr server 配置。 + +如果移动端没有系统 Nerd Font,herdr-gui 会加载内置的 glyph-only Nerd Font +子集,用来显示常见图标字符。 ## Worktree Hooks diff --git a/web/src/App.tsx b/web/src/App.tsx index 691c4b1..7674b9f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -66,6 +66,17 @@ import { normalizeAccentColor, type AccentColor, } from "./appearance"; +import { + LEGACY_MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + MOBILE_TERMINAL_SIDE_SHORTCUTS_STORAGE_KEY, + parseMobileTerminalShortcutRows, + parseMobileTerminalSideShortcuts, + serializeMobileTerminalShortcutRows, + serializeMobileTerminalSideShortcuts, + type MobileTerminalShortcutRows, + type MobileTerminalSideShortcuts, +} from "./mobileTerminalShortcuts"; import { agentClass } from "./utils"; import packageJson from "../package.json"; @@ -88,6 +99,8 @@ const LazyTerminalView = lazy(() => type TerminalViewProps = { paneId?: string; showMobileKeys?: boolean; + mobileShortcuts?: MobileTerminalShortcutRows; + mobileSideShortcuts?: MobileTerminalSideShortcuts; agentHistoryOpen?: boolean; onAgentHistoryOpenChange?: (open: boolean) => void; }; @@ -196,6 +209,30 @@ function loadAccentColor(): AccentColor { return normalizeAccentColor(localStorage.getItem(ACCENT_COLOR_KEY)); } +function loadMobileTerminalShortcuts(): MobileTerminalShortcutRows { + const current = localStorage.getItem( + MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + ); + if (current !== null) return parseMobileTerminalShortcutRows(current); + const legacy = localStorage.getItem( + LEGACY_MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + ); + const migrated = parseMobileTerminalShortcutRows(legacy); + if (legacy !== null) { + localStorage.setItem( + MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + serializeMobileTerminalShortcutRows(migrated), + ); + } + return migrated; +} + +function loadMobileTerminalSideShortcuts(): MobileTerminalSideShortcuts { + return parseMobileTerminalSideShortcuts( + localStorage.getItem(MOBILE_TERMINAL_SIDE_SHORTCUTS_STORAGE_KEY), + ); +} + function loadSidebarActivity(): SidebarActivity { const value = localStorage.getItem(SIDEBAR_ACTIVITY_KEY); return value === "files" || value === "diff" ? value : "workspaces"; @@ -538,9 +575,13 @@ function resizeTargetForSplit( // Render the active tab's Herdr pane layout; single-pane and zoomed tabs keep // the old full terminal view. function TerminalPaneLayout({ + mobileShortcuts, + mobileSideShortcuts, agentHistoryOpen, onAgentHistoryOpenChange, }: { + mobileShortcuts: MobileTerminalShortcutRows; + mobileSideShortcuts: MobileTerminalSideShortcuts; agentHistoryOpen: boolean; onAgentHistoryOpenChange: (open: boolean) => void; }) { @@ -561,6 +602,8 @@ function TerminalPaneLayout({ if (!layout || layout.zoomed || visiblePanes.length <= 1) { return ( @@ -613,6 +656,8 @@ function TerminalPaneLayout({ @@ -694,6 +739,8 @@ function TerminalPaneLayout({ @@ -745,6 +792,10 @@ export default function App() { const [accentColor, setAccentColor] = useState(() => loadAccentColor(), ); + const [mobileTerminalShortcuts, setMobileTerminalShortcuts] = + useState(loadMobileTerminalShortcuts); + const [mobileTerminalSideShortcuts, setMobileTerminalSideShortcuts] = + useState(loadMobileTerminalSideShortcuts); const [sidebarHidden, setSidebarHidden] = useState(false); const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false); const [agentHistoryOpen, setAgentHistoryOpen] = useState(false); @@ -1344,6 +1395,33 @@ export default function App() { document.documentElement.dataset.accent = accentColor; localStorage.setItem(ACCENT_COLOR_KEY, accentColor); }, [accentColor, theme]); + useEffect(() => { + localStorage.setItem( + MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY, + serializeMobileTerminalShortcutRows(mobileTerminalShortcuts), + ); + }, [mobileTerminalShortcuts]); + useEffect(() => { + localStorage.setItem( + MOBILE_TERMINAL_SIDE_SHORTCUTS_STORAGE_KEY, + serializeMobileTerminalSideShortcuts(mobileTerminalSideShortcuts), + ); + }, [mobileTerminalSideShortcuts]); + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key === MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY) { + setMobileTerminalShortcuts( + parseMobileTerminalShortcutRows(event.newValue), + ); + } else if (event.key === MOBILE_TERMINAL_SIDE_SHORTCUTS_STORAGE_KEY) { + setMobileTerminalSideShortcuts( + parseMobileTerminalSideShortcuts(event.newValue), + ); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); useEffect(() => { localStorage.setItem(SIDEBAR_ACTIVITY_KEY, sidebarActivity); }, [sidebarActivity]); @@ -1464,8 +1542,14 @@ export default function App() { @@ -1700,6 +1784,8 @@ export default function App() { > diff --git a/web/src/components/ConfigMenu.tsx b/web/src/components/ConfigMenu.tsx index c7e1547..50a9764 100644 --- a/web/src/components/ConfigMenu.tsx +++ b/web/src/components/ConfigMenu.tsx @@ -24,9 +24,15 @@ import { type AccentColor, } from "../appearance"; import { store, useStore } from "../store"; +import { + mobileTerminalShortcutCount, + type MobileTerminalShortcutRows, + type MobileTerminalSideShortcuts, +} from "../mobileTerminalShortcuts"; import { AutoSyncRepositoriesDialog } from "./AutoSyncRepositoriesDialog"; import { ChangelogDialog } from "./ChangelogDialog"; import { ShortcutLookupDialog } from "./ShortcutLookupDialog"; +import { MobileTerminalShortcutsDialog } from "./MobileTerminalShortcutsDialog"; const APP_VERSION = packageJson.version; export const CONFIG_MENU_ID = "herdr-config-menu"; @@ -43,15 +49,27 @@ type HerdrInfo = { type ConfigMenuProps = { theme: Theme; accentColor: AccentColor; + mobileTerminalShortcuts: MobileTerminalShortcutRows; + mobileTerminalSideShortcuts: MobileTerminalSideShortcuts; onThemeChange: (theme: Theme) => void; onAccentColorChange: (accentColor: AccentColor) => void; + onMobileTerminalShortcutsChange: ( + rows: MobileTerminalShortcutRows, + ) => void; + onMobileTerminalSideShortcutsChange: ( + shortcuts: MobileTerminalSideShortcuts, + ) => void; }; export function ConfigMenu({ theme, accentColor, + mobileTerminalShortcuts, + mobileTerminalSideShortcuts, onThemeChange, onAccentColorChange, + onMobileTerminalShortcutsChange, + onMobileTerminalSideShortcutsChange, }: ConfigMenuProps) { const s = useStore(); const updateAvailable = !!s.updateInfo?.update_available; @@ -70,6 +88,7 @@ export function ConfigMenu({ const [open, setOpen] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [mobileShortcutsOpen, setMobileShortcutsOpen] = useState(false); const [autoSyncOpen, setAutoSyncOpen] = useState(false); const [connectionDetailsOpen, setConnectionDetailsOpen] = useState(false); const [health, setHealth] = useState(null); @@ -278,6 +297,17 @@ export function ConfigMenu({ + } + label="Mobile terminal shortcuts" + description={`${mobileTerminalShortcutCount( + mobileTerminalShortcuts, + )} panel · ${mobileTerminalSideShortcuts.filter(Boolean).length} side`} + onClick={() => { + setOpen(false); + setMobileShortcutsOpen(true); + }} + /> } label="Automatic branch updates" @@ -419,6 +449,14 @@ export function ConfigMenu({ open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> + setMobileShortcutsOpen(false)} + /> setAutoSyncOpen(false)} diff --git a/web/src/components/MobileTerminalShortcutsDialog.tsx b/web/src/components/MobileTerminalShortcutsDialog.tsx new file mode 100644 index 0000000..acd3a1c --- /dev/null +++ b/web/src/components/MobileTerminalShortcutsDialog.tsx @@ -0,0 +1,555 @@ +import { useEffect, useRef, useState } from "react"; +import { + Check, + ChevronsUpDown, + Plus, + RotateCcw, + Trash2, +} from "lucide-react"; +import { + MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW, + MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS, + MOBILE_TERMINAL_SHORTCUT_OPTIONS, + defaultMobileTerminalShortcutRows, + defaultMobileTerminalSideShortcuts, + mobileTerminalShortcutOption, + normalizeMobileTerminalShortcutRows, + normalizeMobileTerminalSideShortcuts, + type MobileTerminalShortcut, + type MobileTerminalShortcutAction, + type MobileTerminalShortcutRows, + type MobileTerminalSideShortcuts, +} from "../mobileTerminalShortcuts"; +import { focusDialogElement } from "./dialogFocus"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +const OPTION_GROUPS = ["Control", "Basic", "Navigation", "Modified"] as const; +let nextShortcutId = 1; + +type SelectedSlot = + | { + area: "panel"; + rowIndex: number; + slotIndex: number; + } + | { + area: "side"; + slotIndex: number; + }; + +function cloneRows(rows: MobileTerminalShortcutRows): MobileTerminalShortcutRows { + return rows.map((row) => + Array.from( + { length: MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW }, + (_, slotIndex) => { + const shortcut = row[slotIndex]; + return shortcut ? { ...shortcut } : null; + }, + ), + ) as MobileTerminalShortcutRows; +} + +function cloneSideShortcuts( + shortcuts: MobileTerminalSideShortcuts, +): MobileTerminalSideShortcuts { + return Array.from( + { length: MAX_MOBILE_TERMINAL_SIDE_SHORTCUTS }, + (_, slotIndex) => { + const shortcut = shortcuts[slotIndex]; + return shortcut ? { ...shortcut } : null; + }, + ); +} + +function newShortcut(): MobileTerminalShortcut { + return { + id: `custom-${Date.now()}-${nextShortcutId++}`, + label: "Esc", + action: "escape", + }; +} + +function ShortcutKeySelect({ + value, + ariaLabel, + onChange, +}: { + value: MobileTerminalShortcutAction; + ariaLabel: string; + onChange: (action: MobileTerminalShortcutAction) => void; +}) { + const currentItemRef = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeValue, setActiveValue] = useState(value); + const currentOption = mobileTerminalShortcutOption(value); + + const setSelectorOpen = (next: boolean) => { + setOpen(next); + setSearch(""); + if (next) setActiveValue(value); + }; + + return ( + + + + + { + event.preventDefault(); + requestAnimationFrame(() => currentItemRef.current?.focus()); + }} + > + + + + No matching keys. + {OPTION_GROUPS.map((group) => ( + + {MOBILE_TERMINAL_SHORTCUT_OPTIONS.filter( + (option) => option.group === group, + ).map((option) => { + const current = option.id === value; + return ( + { + onChange(option.id); + setSelectorOpen(false); + }} + > + {option.label} + {option.defaultButtonLabel} + + ); + })} + + ))} + + + + + ); +} + +export function MobileTerminalShortcutsDialog({ + open, + rows, + sideShortcuts, + onChange, + onSideChange, + onClose, +}: { + open: boolean; + rows: MobileTerminalShortcutRows; + sideShortcuts: MobileTerminalSideShortcuts; + onChange: (rows: MobileTerminalShortcutRows) => void; + onSideChange: (shortcuts: MobileTerminalSideShortcuts) => void; + onClose: () => void; +}) { + const dialogRef = useRef(null); + const labelInputRef = useRef(null); + const rowsRef = useRef(rows); + const sideShortcutsRef = useRef(sideShortcuts); + const onCloseRef = useRef(onClose); + rowsRef.current = rows; + sideShortcutsRef.current = sideShortcuts; + onCloseRef.current = onClose; + const [draft, setDraft] = useState(() => + cloneRows(rows), + ); + const [sideDraft, setSideDraft] = useState(() => + cloneSideShortcuts(sideShortcuts), + ); + const [selectedSlot, setSelectedSlot] = useState(null); + + useEffect(() => { + if (!open) return; + setDraft(cloneRows(rowsRef.current)); + setSideDraft(cloneSideShortcuts(sideShortcutsRef.current)); + setSelectedSlot(null); + const cancelFocus = focusDialogElement(dialogRef.current); + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if ( + document.querySelector( + '[data-mobile-shortcut-key-picker][data-state="open"]', + ) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onCloseRef.current(); + }; + window.addEventListener("keydown", onKey, { capture: true }); + return () => { + cancelFocus(); + window.removeEventListener("keydown", onKey, { capture: true }); + }; + }, [open]); + + if (!open) return null; + + const selectPanelSlot = (rowIndex: number, slotIndex: number) => { + setDraft((current) => { + if (current[rowIndex][slotIndex]) return current; + const next = cloneRows(current); + next[rowIndex][slotIndex] = newShortcut(); + return next; + }); + setSelectedSlot({ area: "panel", rowIndex, slotIndex }); + requestAnimationFrame(() => labelInputRef.current?.focus()); + }; + + const selectSideSlot = (slotIndex: number) => { + setSideDraft((current) => { + if (current[slotIndex]) return current; + const next = cloneSideShortcuts(current); + next[slotIndex] = newShortcut(); + return next; + }); + setSelectedSlot({ area: "side", slotIndex }); + requestAnimationFrame(() => labelInputRef.current?.focus()); + }; + + const updateSelectedShortcut = ( + update: (shortcut: MobileTerminalShortcut) => MobileTerminalShortcut, + ) => { + if (!selectedSlot) return; + if (selectedSlot.area === "side") { + setSideDraft((current) => { + const shortcut = current[selectedSlot.slotIndex]; + if (!shortcut) return current; + const next = cloneSideShortcuts(current); + next[selectedSlot.slotIndex] = update(shortcut); + return next; + }); + return; + } + setDraft((current) => { + const shortcut = + current[selectedSlot.rowIndex][selectedSlot.slotIndex]; + if (!shortcut) return current; + const next = cloneRows(current); + next[selectedSlot.rowIndex][selectedSlot.slotIndex] = update(shortcut); + return next; + }); + }; + + const clearSelectedSlot = () => { + if (!selectedSlot) return; + if (selectedSlot.area === "side") { + setSideDraft((current) => { + const next = cloneSideShortcuts(current); + next[selectedSlot.slotIndex] = null; + return next; + }); + } else { + setDraft((current) => { + const next = cloneRows(current); + next[selectedSlot.rowIndex][selectedSlot.slotIndex] = null; + return next; + }); + } + setSelectedSlot(null); + }; + + const selectedShortcut = selectedSlot + ? selectedSlot.area === "side" + ? sideDraft[selectedSlot.slotIndex] + : draft[selectedSlot.rowIndex][selectedSlot.slotIndex] + : null; + const selectedOption = selectedShortcut + ? mobileTerminalShortcutOption(selectedShortcut.action) + : null; + + return ( +
+
event.stopPropagation()} + > +
+
+

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. +

+
+ +
+ +
+ {draft.map((row, rowIndex) => ( +
+
+ Row {rowIndex + 1} + + {row.filter(Boolean).length} / {MAX_MOBILE_TERMINAL_SHORTCUTS_PER_ROW} + +
+
+ {row.map((shortcut, slotIndex) => { + const selected = + selectedSlot?.area === "panel" && + selectedSlot.rowIndex === rowIndex && + selectedSlot.slotIndex === slotIndex; + const option = shortcut + ? mobileTerminalShortcutOption(shortcut.action) + : null; + return ( + + ); + })} +
+
+ ))} +
+ +
+
+
+ Right-side buttons + Original Up / Dn position, top to bottom +
+ {sideDraft.filter(Boolean).length} / 4 +
+
+ {sideDraft.map((shortcut, slotIndex) => { + const selected = + selectedSlot?.area === "side" && + selectedSlot.slotIndex === slotIndex; + const option = shortcut + ? mobileTerminalShortcutOption(shortcut.action) + : null; + return ( + + ); + })} +
+
+ +
+ {selectedShortcut && selectedSlot ? ( + <> +
+
+ + {selectedSlot.area === "side" + ? `Right-side slot ${selectedSlot.slotIndex + 1}` + : `Row ${selectedSlot.rowIndex + 1}, slot ${selectedSlot.slotIndex + 1}`} + + Edit this button in place +
+ +
+
+ +
+ Key + { + const nextOption = mobileTerminalShortcutOption(action); + updateSelectedShortcut((current) => ({ + ...current, + action, + label: + !current.label.trim() || + current.label === selectedOption?.defaultButtonLabel + ? nextOption?.defaultButtonLabel ?? current.label + : current.label, + })); + }} + /> +
+
+ + ) : ( +
+ Select a filled button to edit it, or select an empty + slot to + add one. +
+ )} +
+ +
+ + + + +
+
+
+ ); +} diff --git a/web/src/components/OverlayScrollbarLayer.tsx b/web/src/components/OverlayScrollbarLayer.tsx index db05935..522ce10 100644 --- a/web/src/components/OverlayScrollbarLayer.tsx +++ b/web/src/components/OverlayScrollbarLayer.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { calculateOverlayThumb, + overlayScrollbarExcludedElement, type OverlayThumbGeometry, } from "./overlayScrollbar"; @@ -52,6 +53,7 @@ function hasScrollableOverflow(element: HTMLElement) { function findScrollableElement(target: EventTarget | null) { let element = target instanceof HTMLElement ? target : null; + if (element && overlayScrollbarExcludedElement(element)) return null; while (element && element !== document.documentElement) { if (hasScrollableOverflow(element)) return element; const xterm = element.closest(".xterm"); @@ -184,9 +186,16 @@ export function OverlayScrollbarLayer() { const onScroll = (event: Event) => { const target = event.target; - if (target instanceof HTMLElement && hasScrollableOverflow(target)) { - refresh(target); + if (!(target instanceof HTMLElement)) return; + if (overlayScrollbarExcludedElement(target)) { + if (targetRef.current && overlayScrollbarExcludedElement(targetRef.current)) { + targetRef.current = null; + setVisible(false); + setLayout(null); + } + return; } + if (hasScrollableOverflow(target)) refresh(target); }; const onPointerMove = (event: PointerEvent) => { if (dragRef.current) return; diff --git a/web/src/components/ShortcutLookupDialog.tsx b/web/src/components/ShortcutLookupDialog.tsx index 5553fa4..6e07c89 100644 --- a/web/src/components/ShortcutLookupDialog.tsx +++ b/web/src/components/ShortcutLookupDialog.tsx @@ -58,10 +58,11 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ { title: "Mobile Terminal", shortcuts: [ - { keys: "PgUp / PgDn", description: "Scroll terminal history using the shortcut bar" }, - { keys: "Ctrl+C", description: "Interrupt the active terminal process" }, - { keys: "Ctrl+D", description: "Send EOF to the active terminal process" }, - { keys: "Esc", description: "Send Escape to the active terminal process" }, + { keys: "Shortcut panel", description: "Send configured terminal keys from up to two aligned rows" }, + { keys: "PgUp / PgDn", description: "Scroll terminal history by one page" }, + { keys: "A-PgUp / A-PgDn", description: "Scroll terminal history by half a page" }, + { keys: "Side buttons", description: "Run up to four configured actions at the terminal edge" }, + { keys: "Menu", description: "Customize the 2-by-8 panel and four side buttons" }, ], }, ]; diff --git a/web/src/components/TerminalView.tsx b/web/src/components/TerminalView.tsx index f985a8f..5845669 100644 --- a/web/src/components/TerminalView.tsx +++ b/web/src/components/TerminalView.tsx @@ -4,6 +4,7 @@ import { useLayoutEffect, useRef, useState, + type CSSProperties, } from "react"; import { Terminal } from "@xterm/xterm"; import type { IBufferLine, ILink } from "@xterm/xterm"; @@ -61,6 +62,15 @@ import { terminalAttachWatchdogMs, terminalRelayViewportSize, } from "../terminalResize"; +import { + defaultMobileTerminalShortcutRows, + defaultMobileTerminalSideShortcuts, + mobileTerminalShortcutOption, + type MobileTerminalShortcut, + type MobileTerminalShortcutRows, + type MobileTerminalSideShortcuts, +} from "../mobileTerminalShortcuts"; +import { mobileTerminalShortcutExecution } from "../mobileTerminalShortcutAction"; const SYSTEM_CLIPBOARD = "c" as ClipboardSelectionType; @@ -380,11 +390,15 @@ function withTimeout( export function TerminalView({ paneId, showMobileKeys = true, + mobileShortcuts = defaultMobileTerminalShortcutRows(), + mobileSideShortcuts = defaultMobileTerminalSideShortcuts(), agentHistoryOpen: controlledAgentHistoryOpen, onAgentHistoryOpenChange, }: { paneId?: string; showMobileKeys?: boolean; + mobileShortcuts?: MobileTerminalShortcutRows; + mobileSideShortcuts?: MobileTerminalSideShortcuts; agentHistoryOpen?: boolean; onAgentHistoryOpenChange?: (open: boolean) => void; }) { @@ -1341,6 +1355,26 @@ export function TerminalView({ return () => cancelAnimationFrame(frame); }, [agentHistoryOpen, fitVisibleTerminal]); + const runMobileShortcut = (shortcut: MobileTerminalShortcut) => { + const execution = mobileTerminalShortcutExecution(shortcut.action); + if (!execution) return; + if (execution.type === "scroll") { + scrollPage(execution.direction, execution.amount); + } else { + sendControl(execution.bytes); + } + }; + const visibleMobileShortcutRows = mobileShortcuts.map((row) => + row.filter((shortcut) => shortcut !== null), + ); + const visibleMobileSideShortcuts = mobileSideShortcuts.filter( + (shortcut) => shortcut !== null, + ); + const visibleMobileShortcutColumns = Math.max( + 1, + ...visibleMobileShortcutRows.map((row) => row.length), + ); + if (!pane) { return ( <> @@ -1393,7 +1427,8 @@ export function TerminalView({ /> ) : null} - {showMobileKeys ? ( + {showMobileKeys && + visibleMobileShortcutRows.some((row) => row.length > 0) ? (
- - - - - - - + {visibleMobileShortcutRows.map((row, rowIndex) => ( +
+ {row.map((shortcut) => { + const option = mobileTerminalShortcutOption( + shortcut.action, + ); + return ( + + ); + })} +
+ ))} +
) : null} - {showMobileKeys ? ( -
- - + {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;