From 93d7df8feca6ca1320111a41feb1abbf7271db6d Mon Sep 17 00:00:00 2001 From: Tamas Krasser Date: Sun, 11 Oct 2026 14:34:33 +0200 Subject: [PATCH 1/6] feat(shortcuts): record and preview exact custom bindings --- AGENTS.md | 2 +- CHANGELOG.md | 1 + DESIGN.md | 16 +- docs/development.md | 1 + docs/guide.md | 30 ++- scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 4 +- .../key-bar-customization-demo-regression.ts | 29 ++- scripts/shortcut-recorder-demo-regression.ts | 223 ++++++++++++++++++ scripts/terminal-input-regression.ts | 14 +- src/AGENTS.md | 4 +- src/components/SettingsDialog.css | 8 - src/components/SettingsDialog.tsx | 45 +--- src/components/ShortcutRecorder.css | 61 +++++ src/components/ShortcutRecorder.tsx | 105 +++++++++ src/components/VoiceInput.tsx | 6 +- src/lib/i18n.ja.ts | 22 +- src/lib/i18n.ko.ts | 22 +- src/lib/i18n.zh.ts | 22 +- src/lib/settings.test.ts | 8 + src/lib/shortcutBindings.ts | 83 ++++++- src/lib/shortcuts.test.ts | 94 +++++++- src/lib/shortcuts.ts | 61 ++--- src/lib/useFocusTrap.ts | 2 +- 24 files changed, 724 insertions(+), 140 deletions(-) create mode 100644 scripts/shortcut-recorder-demo-regression.ts create mode 100644 src/components/ShortcutRecorder.css create mode 100644 src/components/ShortcutRecorder.tsx diff --git a/AGENTS.md b/AGENTS.md index 82e0bd0b6e..2fcedcdc4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ The app is only a bridge: herdr owns every pty, scrollback and agent state. - Component CSS is colocated and uses tokens only: no color literals, no `!important`. `DESIGN.md` must match the token values in `src/styles.css`; no test checks this, so update both together. - Endless animations use `steps()` or `var(--ease-pulse)` (`src/motion.test.ts` enforces it). - i18n: the English string is the key and must be a string literal. Every new `t("…")` needs an entry in `src/lib/i18n.ko.ts`, `i18n.ja.ts` and `i18n.zh.ts`; `i18n.test.ts` fails on a missing, unused or untranslated entry. -- Shortcuts are Mod+Shift+key so the pty keeps Ctrl+key. To add one, update `SHORTCUTS`, `KEY_TO_ID` and the switch in `src/lib/shortcuts.ts`. +- Default shortcuts are Mod+Shift+key so the pty keeps Ctrl+key; explicit device-local overrides retain their exact modifiers. To add one, update `SHORTCUTS`, `KEY_TO_ID` and the switch in `src/lib/shortcuts.ts`. - Icons come from lucide-react only; brand marks live in `AgentMark.tsx`. When icon files change, bump the `?v=` query in `index.html` and `CACHE_NAME` in `public/sw.js` together. - UI wording: "New workspace", not "New session". "Session" means the herdr server session or an agent's history. - There is no linter or formatter. Through `tsconfig.tools.json`, `bun run typecheck` also checks diff --git a/CHANGELOG.md b/CHANGELOG.md index e12f01a56c..a2d7c431b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- Settings → Shortcuts records exact key and modifier combinations with a preview and explicit Save/Cancel. A manual key and modifier editor works without a hardware keyboard; defaults, disabled bindings and existing Mod+Shift settings remain supported. - Native conversation transcripts for Hermes Agent: turns, thinking blocks, and tool calls are read directly from its SQLite session store. ([#604](https://github.com/devswha/herdr-web-ui/pull/604) by @RikiSanjayaa) This server change needs a `REMOTE_BUNDLE_VERSION` bump at release so remote PCs can read Hermes transcripts. - **Deep links.** The address now says where you are, and opens there: the PC, the workspace (by id and a short slug of its name, so `?ws=w2K-herdr-web-ui&pane=w2K:p1&view=chat` reads like the place), the pane and its lens, an open file, a Settings page and group (`&settings=about§ion=updates`), the New workspace dialog with its fields filled (`?new=workspace&cwd=…&name=…&agent=…`; a link never creates anything), and a collapsed sidebar (`&sidebar=hidden`). Opening another pane is a history entry, so Back returns to the last one. A link to a pane that has closed opens its workspace, a link to nothing open says so, and **Copy link** in the header's More menu copies the address on a phone. diff --git a/DESIGN.md b/DESIGN.md index cf761b1d1a..efb57ab628 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -701,7 +701,7 @@ One set for both themes: the card is island black wherever it shows. fitted inside each rendered pane; layout rectangles are never used as PTY dimensions. - Plain right-click can be passed to a mouse-reporting terminal when enabled for that pane. Modified clicks, pane frames and non-reporting/read-only terminals still open the menu. - Native app shortcuts stay Mod+Shift in the browser, so Ctrl+key continues to reach the PTY. + Default app shortcuts use Mod+Shift in the browser, so Ctrl+key continues to reach the PTY unless the user explicitly assigns that combination. ### Worktree dialog (`.worktree-modal`) - From a workspace row's menu, as herdr's prefix+shift+g: **New worktree** asks for the branch @@ -1287,12 +1287,16 @@ One set for both themes: the card is island black wherever it shows. ready and unknown draw nothing there, and unknown uses a dashed badge edge elsewhere. - Touch targets grow to `--touch-target`; fields stay `--fs-input` where mobile zoom is a risk. - `prefers-reduced-motion` is honored. Lucide/inline SVG decoration is hidden from assistive tech. -- Global shortcuts use the convention **Mod+Shift+key**: Mod is Command on Apple platforms and Ctrl - elsewhere. Settings lists the global bindings and allows changing their final key or disabling - them, with duplicate assignments blocked and known browser/OS reservations warned about. - Reservations vary by browser and installed-app mode; a warning is not a guarantee of delivery. +- Default global shortcuts use **Mod+Shift+key**: Mod is Command on Apple platforms and Ctrl + elsewhere. Settings records exact modifier combinations into a preview, with explicit Save/Cancel. + A key dropdown and custom-character field with modifier checkboxes work without a hardware keyboard. + Duplicate exact combinations, including fixed hold-to-dictate, block Save; restoration and disabling + are separate controls. Expanded editors own their keys before global capture listeners run. + Known browser/OS reservations and text/terminal overrides show warnings. Reservations vary by + browser and installed-app mode; manual editing cannot guarantee delivery. Bindings belong to this browser, not the remote PC or pane. Hold-to-dictate remains fixed. -- Text-field selection and IME composition keep their keys. Focus-local list/tab navigation and +- Default text-field arrow selection and IME composition keep their keys. Explicit custom bindings + may replace native text or terminal keys. Focus-local list/tab navigation and reordering are separate from global bindings; they must not intercept terminal input. Global actions target the selected pane, and a handled shortcut must never also reach its PTY. - On touch screens app actions stay in buttons and menus, including the palette in More on phones. diff --git a/docs/development.md b/docs/development.md index 04d1771105..334449a25a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,6 +84,7 @@ bun scripts/sticky-modifiers-regression.ts # mobile held keys through real legac bun scripts/key-bar-customization-demo-regression.ts # mobile key layout, saved combinations and migration on demo fixtures bun scripts/key-bar-hold-repeat-regression.ts # phone touch-arrow taps, holds, swipes and sticky Ctrl on demo fixtures bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px and a 320px phone, and Back stepping out of the dialog +bun scripts/shortcut-recorder-demo-regression.ts # exact shortcut recording, preview/Save, manual touch editing and composer isolation on fictional demo fixtures bun scripts/chat-browser-qa.ts # chat lens end to end bun scripts/output-browser-qa.ts # terminal output flow control end to end bun scripts/math-browser-qa.ts # chat math: KaTeX loads with the first expression diff --git a/docs/guide.md b/docs/guide.md index 2477e3cf71..d6ddf12372 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -475,7 +475,7 @@ A message holds the event (`install` or `update`), a random ID made on this PC, ## Keyboard shortcuts -`Mod` is **⌘** on Apple platforms and **Ctrl** elsewhere. Global app shortcuts add Shift, so the terminal keeps its own Ctrl keys. These are the defaults: +`Mod` is **⌘** on Apple platforms and **Ctrl** elsewhere. Default global app shortcuts add Shift, so the terminal keeps its own Ctrl keys. These are the defaults: | Shortcut | Action | | --- | --- | @@ -486,14 +486,26 @@ A message holds the event (`install` or `update`), a random ID made on this PC, | `Mod+Shift+↑` / `↓` | Previous / next pane | | `Mod+Shift+,` | Settings | -**Settings → Shortcuts** changes the final key of each app binding or turns it off. Settings -are saved in this browser, not on the remote PC. The command palette shows the current bindings -and omits disabled ones. Reset shortcuts restores the defaults. Hold-to-dictate -(`Mod+Shift+Space`) remains fixed. - -Known browser/OS reservations are marked, but other combinations can also be intercepted by -your browser, OS or extensions. Installed-app mode may behave differently. While an IME is -composing, keys stay with it. In text fields, Mod+Shift+arrows keep their text-selection behavior. +**Settings → Shortcuts** records a key with its exact Ctrl, Command/Meta, Alt and Shift modifiers. +Click the current binding, press a combination, check the preview, then **Save** or **Cancel**. +Nothing changes until Save. Escape cancels; plain Tab and Shift+Tab move focus. To bind these +keys, or without a hardware keyboard, choose **Edit**, select a key (or type a custom character +or key name), and check the modifiers. Unicode letters, Home/End, Page Up/Down and function keys +are supported. For shifted digit symbols and comma, enter the base digit or comma. Non-Latin +layouts use the physical Latin-letter position, while Latin accented letters keep their own +character. Modifier-only, dead-key and IME events are not recorded. + +Settings are saved in this browser, not on the remote PC. Existing saved Mod+Shift bindings keep +working. The command palette shows current bindings and omits disabled ones. Each row can restore +its default or turn the binding off; **Reset shortcuts** restores all defaults. Exact duplicate +combinations, including the fixed hold-to-dictate binding (`Mod+Shift+Space`), block Save. + +Known browser/OS reservations show a warning, but other combinations can also be intercepted by +your browser, OS or extensions. Some keys never reach the page; manual editing cannot bypass +those reservations. Installed-app mode may behave differently. Bare keys, Shift-only bindings +and Ctrl combinations can replace typing, native text editing or terminal commands; choose them +with care. While an IME is composing, keys stay with it. Default Mod+Shift+arrows keep their +text-selection behavior in text fields; explicit custom arrow bindings take precedence. List and tab controls also have focus-local keys; those do not become global terminal shortcuts. On a focused tab, Alt+Shift+Left/Right reorders it and Shift+F10 opens its menu. On the terminal scrollbar, arrows move a line, Page Up/Down a viewport, and Home/End the start/end of history. diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 493d4e5946..cabb589738 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -66,6 +66,7 @@ run_script scripts/terminal-viewport-regression.ts run_script scripts/key-bar-customization-demo-regression.ts run_script scripts/key-bar-hold-repeat-regression.ts run_script scripts/settings-pages-demo-regression.ts +run_script scripts/shortcut-recorder-demo-regression.ts run_script scripts/deep-link-demo-regression.ts run_script scripts/chat-history-browser-qa.ts run_script scripts/chat-fallback-browser-qa.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index dd7f92a911..4a3395cd30 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -59,9 +59,7 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(33); - expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); - expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts"); + expect(rows(text)).toHaveLength(34); expect(rows(text).map((row) => row.script)).toContain("scripts/file-preview-text-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/terminal-viewport-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/palette-machines-demo-regression.ts"); diff --git a/scripts/key-bar-customization-demo-regression.ts b/scripts/key-bar-customization-demo-regression.ts index c5265299bf..d30ac531c5 100644 --- a/scripts/key-bar-customization-demo-regression.ts +++ b/scripts/key-bar-customization-demo-regression.ts @@ -332,24 +332,29 @@ try { await closePalette(); dialog = await openShortcutSettings(); - let sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); - await sidebarShortcut.selectOption("x"); - await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === "x", SETTINGS_KEY, { timeout: 5_000 }); + const sidebarGroup = () => dialog.getByRole("group", { name: "Toggle sidebar", exact: true }); + let sidebarShortcut = sidebarGroup().getByRole("button", { name: "Record shortcut for Toggle sidebar", exact: true }); + const editSidebarShortcut = async (key: string): Promise => { + await sidebarGroup().getByRole("button", { name: "Edit shortcut for Toggle sidebar", exact: true }).click(); + await sidebarGroup().getByRole("combobox", { name: "Shortcut key", exact: true }).selectOption(key); + await sidebarGroup().getByRole("button", { name: "Save", exact: true }).click(); + }; + await editSidebarShortcut("x"); + await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"]?.key === "x", SETTINGS_KEY, { timeout: 5_000 }); await closeSettings(page, dialog); action = await openSidebarAction(); await expectSidebarHint(action, "X"); await closePalette(); await page.reload(); await ready(page); - const savedOverrides = (await settingsOf(page)).shortcutOverrides as Record; - assert.equal(savedOverrides["toggle-sidebar"], "x", "a shortcut rebinding survives reload"); + const savedOverrides = (await settingsOf(page)).shortcutOverrides as Record; + assert.equal(savedOverrides["toggle-sidebar"]?.key, "x", "a shortcut rebinding survives reload"); action = await openSidebarAction(); await expectSidebarHint(action, "X"); await closePalette(); dialog = await openShortcutSettings(); - sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); - await sidebarShortcut.selectOption("off"); + await sidebarGroup().getByRole("button", { name: "Disable shortcut for Toggle sidebar", exact: true }).click(); await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === null, SETTINGS_KEY, { timeout: 5_000 }); await closeSettings(page, dialog); action = await openSidebarAction(); @@ -357,15 +362,13 @@ try { await closePalette(); dialog = await openShortcutSettings(); - sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); - assert.match(await sidebarShortcut.locator('option[value="t"]').innerText(), /Reserved/); - await sidebarShortcut.selectOption("t"); + await editSidebarShortcut("t"); await dialog.getByText(`Your browser or operating system may intercept ${modifierLabel}+⇧+T.`, { exact: true }).waitFor({ timeout: 5_000 }); - assert.equal(((await settingsOf(page)).shortcutOverrides as Record)["toggle-sidebar"], "t", "a reservation warns without silently changing the configured key"); + assert.equal(((await settingsOf(page)).shortcutOverrides as Record)["toggle-sidebar"]?.key, "t", "a reservation warns without silently changing the configured key"); await dialog.getByRole("button", { name: "Reset shortcuts", exact: true }).click(); await page.waitForFunction((key) => Object.keys(JSON.parse(localStorage.getItem(key)!).shortcutOverrides).length === 0, SETTINGS_KEY, { timeout: 5_000 }); - sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true }); - await page.waitForFunction(() => (document.querySelector('.settings-dialog select[aria-label="Toggle sidebar"]')?.value ?? "") === "default", undefined, { timeout: 5_000 }); + sidebarShortcut = sidebarGroup().getByRole("button", { name: "Record shortcut for Toggle sidebar", exact: true }); + assert.equal(await sidebarShortcut.innerText(), `${modifierLabel}+⇧+B`); if (evidence) { mkdirSync(evidence, { recursive: true }); await sidebarShortcut.scrollIntoViewIfNeeded(); diff --git a/scripts/shortcut-recorder-demo-regression.ts b/scripts/shortcut-recorder-demo-regression.ts new file mode 100644 index 0000000000..45c4bb7626 --- /dev/null +++ b/scripts/shortcut-recorder-demo-regression.ts @@ -0,0 +1,223 @@ +/** Shortcut editing and dispatch on the production client over fictional demo fixtures only. */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Page } from "playwright-core"; +import { buildDemoApp } from "./demo-build.ts"; +import { openSettingsPage } from "./settings-page.ts"; +import panes from "../site/demo/fixtures/panes.json"; + +const app = mkdtempSync(join(tmpdir(), "herdr-shortcut-recorder-")); +const settingsKey = "herdr-web-ui:settings"; +const overridesOf = (page: Page): Promise> => page.evaluate((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides, settingsKey); +const openShortcuts = async (page: Page) => { + await page.keyboard.press("Meta+Shift+Comma"); + await openSettingsPage(page, "Shortcuts"); +}; +const rowOf = (page: Page, name: string) => page.getByRole("group", { name, exact: true }); +const record = async (page: Page, action: string, chord: string) => { + await page.getByRole("button", { name: `Record shortcut for ${action}`, exact: true }).click(); + await page.keyboard.press(chord); +}; + +try { + await buildDemoApp(app); + const index = join(app, "index.html"); + const captureInput = ``; + writeFileSync(index, readFileSync(index, "utf8").replace(/\n ${captureInput}\n