From ce5cbb019279d0cfad93e0ff7aecd24393fe49c8 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 13:04:39 +1000
Subject: [PATCH 01/10] feat(shortcuts): add rebindable keyboard shortcut
settings
Settings gains a Shortcuts section that lists every host binding and every
active plugin contribution, grouped by owner, with search, inline key capture,
per-row Change/Reset and Reset all. Overrides persist in the device-local
buzz-shortcut-bindings.v1 preference and are resolved by the dispatcher at
match time.
Relation to the plugin architecture: the plugin-facing contract is unchanged.
Plugins keep calling ctx.shortcuts.register with their default binding via
@buzz/author and never see, store or re-register for an override. The page
reads the dispatcher's own registries (hostSnapshot/hostSubscribe for host
bindings, snapshot/subscribe for plugin contributions), so it cannot drift
from what fires and follows plugin enable/disable live. Overrides are keyed by
the registry identity the dispatcher already uses (bare id for host bindings,
pluginId/id for contributions), so they survive disable, re-enable and
replacement, and orphaned entries are ignored rather than deleted. Host chords
remain reserved: the page refuses chords already used by any listed shortcut
and warns about chords the message editor handles locally. There is no
Settings extension point and no Rust catalog change; this is host-owned
feature code modelled on Appearance. Docs updated accordingly.
FOUNDATION files touched (minimal, additive, behaviour-preserving):
- src/features/shortcuts/service.ts: optional BindingOverrides constructor
argument consulted at match time (falls back to the registered binding);
host-only hostSnapshot/hostSubscribe so the Settings page can list host
bindings and their defaults. Plugin snapshot/subscribe and matching rules
are unchanged.
- src/features/shortcuts/bindings.ts: export isKeyBinding (the existing
per-binding validation) and add sameBinding; matches() accepts an alias
array so effective bindings resolve in one place. Validation semantics are
unchanged.
- src/app/services.ts: construct/dispose the shortcut bindings store, pass
it to ShortcutsService, and expose it on AppServices.
- src/app/App.tsx: hand the shortcuts service and bindings store to Settings.
Provisional components awaiting a design pass (plain black-and-white on
standard tokens, kept outside src/shared/design-system/ui/, marked with a
DESIGN PASS PENDING file comment and data-design-pass="pending"):
- src/features/shortcuts/KeyCombo.tsx: key-combo chip.
- src/features/shortcuts/KeyCaptureControl.tsx: inline key-capture control.
Also adds formatBinding (glyphs in Control/Option/Shift/Command order on Apple
platforms, Ctrl+Shift+K style elsewhere, accessible plain-words label) and uses
it for the search and terminal hints; unit tests for the store, formatter,
dispatcher overrides and the page; a Playwright journey rebinding the example
plugin's shortcut; and the Settings tab-order assertion.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/design-system.md | 17 +-
docs/plugin-architecture.md | 22 +-
src/app/App.tsx | 2 +
src/app/Settings.tsx | 16 +
src/app/ShortcutSettings.test.tsx | 427 ++++++++++++++++++
src/app/ShortcutSettings.tsx | 374 +++++++++++++++
src/app/navigation.ts | 11 +-
src/app/services.ts | 6 +-
src/app/shell/PageSearch.tsx | 27 +-
src/bundled/terminal/index.tsx | 9 +-
.../shortcuts/KeyCaptureControl.module.css | 12 +
src/features/shortcuts/KeyCaptureControl.tsx | 87 ++++
src/features/shortcuts/KeyCombo.module.css | 19 +
src/features/shortcuts/KeyCombo.tsx | 31 ++
src/features/shortcuts/bindings.ts | 42 +-
src/features/shortcuts/format.test.ts | 66 +++
src/features/shortcuts/format.ts | 65 +++
src/features/shortcuts/preferences.test.ts | 141 ++++++
src/features/shortcuts/preferences.ts | 128 ++++++
src/features/shortcuts/service.test.ts | 94 +++-
src/features/shortcuts/service.ts | 36 +-
src/shared/design-system/icons/index.ts | 2 +
tests/browser/settings.spec.mjs | 4 +
tests/browser/shortcuts.spec.mjs | 61 +++
24 files changed, 1665 insertions(+), 34 deletions(-)
create mode 100644 src/app/ShortcutSettings.test.tsx
create mode 100644 src/app/ShortcutSettings.tsx
create mode 100644 src/features/shortcuts/KeyCaptureControl.module.css
create mode 100644 src/features/shortcuts/KeyCaptureControl.tsx
create mode 100644 src/features/shortcuts/KeyCombo.module.css
create mode 100644 src/features/shortcuts/KeyCombo.tsx
create mode 100644 src/features/shortcuts/format.test.ts
create mode 100644 src/features/shortcuts/format.ts
create mode 100644 src/features/shortcuts/preferences.test.ts
create mode 100644 src/features/shortcuts/preferences.ts
diff --git a/docs/design-system.md b/docs/design-system.md
index c5ccabe4d..797b64ba0 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -117,9 +117,24 @@ font size by the scale again. Use unitless or scaled line-height so enlarged tex
does not overlap. Independent plugins that hard-code sizes and third-party shadow
widgets need their own adapter; this is not a forced CSS rewrite of arbitrary code.
+Settings → Shortcuts lists every host and active plugin shortcut from the live
+dispatcher, grouped by owner, with search, per-row Change/Reset and Reset all. It
+is built from existing components (`SearchField`, `Button`, `NavigationSection`,
+the Plugins-list row pattern) and `formatBinding`, which renders chords as glyphs
+in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words
+elsewhere (Ctrl+Shift+K), with a plain-words accessible label. Two pieces are
+provisional and await a design pass: the key-combo `` chip
+(`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control
+(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately
+black-and-white on standard text, surface, border and radius tokens, live outside
+`src/shared/design-system/ui/`, and are marked with a `DESIGN PASS PENDING` file
+comment and `data-design-pass="pending"` on their root so they are greppable.
+
`tests/browser/shortcuts.spec.mjs` covers real key dispatch to Settings and actual
message/composer text, draft/node preservation, reset/limits/reload, modal/editor/
-Shadow DOM guards, and the independent example's disable/re-enable path. These
+Shadow DOM guards, the independent example's disable/re-enable path, and rebinding
+that example's shortcut from Settings → Shortcuts (host conflict refused, new chord
+fires, old chord does not, persists across reload, reset restores). These
Chromium/WebKit checks use a fixture broker, not native menu accelerators. An
attended desktop shortcut try remains necessary for native acceptance.
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 79d88d846..51f89bad4 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -515,5 +515,23 @@ self-contained external plugin using the real service without a DOM listener.
The generated type-only `@buzz/author` exports `Shortcuts`, `Shortcut`, `KeyBinding`
and `RegisteredShortcut`. This is a host-matched preview: older hosts without the
`shortcuts` capability cannot activate such a plugin. `apiVersion: 1` alone is not
-runtime feature negotiation. Chords, user rebinding, conflict UI and command palettes
-are outside this initial contract.
+runtime feature negotiation. Multi-key chord sequences and command palettes are
+outside this initial contract.
+
+Users can rebind any registered shortcut in Settings → Shortcuts without plugin
+changes. The page lists host bindings and every active plugin contribution from the
+dispatcher's own `hostSnapshot`/`snapshot` registries, grouped by owner, so it
+cannot drift from what fires. Overrides live in the host-owned device-local
+`buzz-shortcut-bindings.v1` preference, keyed by the registry identity the
+dispatcher already uses: the bare id for host bindings and `pluginId/id` for plugin
+contributions. The dispatcher resolves the effective binding at match time, so a
+plugin keeps registering its default and never sees, stores or re-registers for
+an override; the override follows the plugin across disable, re-enable and
+replacement, and an override whose owner is no longer installed is ignored rather
+than deleted. Rebinding replaces an alias set with the single chosen chord; reset
+restores every alias. Host chords stay reserved: the page refuses to assign a chord
+that another listed shortcut already uses, host or plugin, and warns when a chord
+is one the message editor handles locally. `formatBinding` in
+`features/shortcuts/format.ts` renders any `KeyBinding` for the current platform;
+plugins that print their own hint (the bundled terminal does) show their registered
+default because overrides are host state.
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 96507bf82..e1d43846f 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -106,6 +106,8 @@ export function App({ services }: { services: AppServices }) {
plugins={plugins}
communities={services.communities}
appearance={services.appearance}
+ shortcuts={services.shortcuts}
+ shortcutBindings={services.shortcutBindings}
notifications={services.notifications}
navigation={route.request}
onSection={(section) =>
diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx
index 17040c385..67fdbf4dd 100644
--- a/src/app/Settings.tsx
+++ b/src/app/Settings.tsx
@@ -11,6 +11,7 @@ import {
PaletteIcon,
BellIcon,
ChatCircleIcon,
+ KeyboardIcon,
WrenchIcon,
} from "../shared/design-system/icons/index";
import type { PluginManager } from "../plugins/manager";
@@ -22,6 +23,9 @@ import type { Appearance } from "../shared/theme/service";
import { AppearanceSettings } from "./AppearanceSettings";
import { NotificationSettings } from "./NotificationSettings";
import type { NotificationsService } from "../features/notifications/service";
+import type { ShortcutsService } from "../features/shortcuts/service";
+import type { ShortcutBindings } from "../features/shortcuts/preferences";
+import { ShortcutSettings } from "./ShortcutSettings";
import { DeveloperSettings } from "./DeveloperSettings";
import { MessageSettings } from "./MessageSettings";
@@ -31,6 +35,7 @@ const baseSections: Section[] = [
{ id: "profile", label: "Profile", icon: UserIcon },
{ id: "plugins", label: "Plugins", icon: SquaresFourIcon },
{ id: "appearance", label: "Appearance", icon: PaletteIcon },
+ { id: "shortcuts", label: "Shortcuts", icon: KeyboardIcon },
{ id: "messages", label: "Messages", icon: ChatCircleIcon },
{ id: "notifications", label: "Notifications", icon: BellIcon },
];
@@ -49,6 +54,8 @@ export function Settings({
plugins,
communities,
appearance,
+ shortcuts,
+ shortcutBindings,
notifications,
navigation,
onSection,
@@ -56,6 +63,8 @@ export function Settings({
plugins: PluginManager;
communities: Communities;
appearance: Appearance;
+ shortcuts: ShortcutsService;
+ shortcutBindings: ShortcutBindings;
notifications: NotificationsService;
navigation?:
| import("../features/navigation/service").PageNavigation
@@ -117,6 +126,13 @@ export function Settings({
+
+
+
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
new file mode 100644
index 000000000..49f33a1bb
--- /dev/null
+++ b/src/app/ShortcutSettings.test.tsx
@@ -0,0 +1,427 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { Context } from "@deepseek-ai/cordis";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import { ShortcutSettings } from "./ShortcutSettings";
+import type { Shortcut } from "../features/shortcuts/bindings";
+import {
+ createShortcutBindings,
+ SHORTCUT_BINDINGS_KEY,
+} from "../features/shortcuts/preferences";
+import { ShortcutsService } from "../features/shortcuts/service";
+import type { PluginManager } from "../plugins/manager";
+import type { PluginInfo } from "../plugins/types";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ localStorage.clear();
+});
+
+const info = (id: string, name: string): PluginInfo => ({
+ manifest: { id, name, apiVersion: 1 },
+ source: "external",
+ enabled: true,
+ revision: "one",
+ previous: null,
+ reloadable: false,
+ error: null,
+});
+const catalog = (
+ plugins: PluginInfo[],
+): Pick => {
+ const snapshot = {
+ configuration: {
+ status: "ready" as const,
+ catalog: { profile: "", location: "", plugins },
+ externalPluginsPaused: false,
+ },
+ activation: {},
+ busy: false,
+ error: null,
+ refreshError: null,
+ };
+ return { subscribe: () => () => {}, snapshot: () => snapshot };
+};
+
+// jsdom reports an empty platform, so the real dispatcher on `window` treats
+// Control as Mod; the page follows the same test unless told otherwise.
+async function harness() {
+ const root = new Context();
+ const inactive = new Set();
+ const statusListeners = new Set<() => void>();
+ root.provide("pluginStatus", {
+ isActive: (id: string) => !inactive.has(id),
+ subscribe: (listener: () => void) => {
+ statusListeners.add(listener);
+ return () => {
+ statusListeners.delete(listener);
+ };
+ },
+ });
+ const bindings = createShortcutBindings(window);
+ const shortcuts = new ShortcutsService(root, window, bindings);
+ const runs = {
+ settings: vi.fn(),
+ search: vi.fn(),
+ grow: vi.fn(),
+ increment: vi.fn(),
+ terminal: vi.fn(),
+ };
+ shortcuts.registerHost({
+ id: "settings",
+ title: "Open Settings",
+ binding: { key: ",", mod: true },
+ run: runs.settings,
+ });
+ shortcuts.registerHost({
+ id: "global-search",
+ title: "Search Buzz",
+ binding: { key: "k", mod: true },
+ run: runs.search,
+ });
+ shortcuts.registerHost({
+ id: "font-increase",
+ title: "Increase text size",
+ binding: [
+ { key: "=", mod: true },
+ { key: "+", mod: true },
+ { key: "=", mod: true, shift: true },
+ { key: "+", mod: true, shift: true },
+ ],
+ run: runs.grow,
+ });
+ const contribute = (id: string, shortcut: Shortcut) =>
+ root
+ .extend({ pluginOwner: { id, revision: "one" } })
+ .plugin((ctx) => {
+ ctx.shortcuts.register(shortcut);
+ })
+ .await();
+ await contribute("example.counter", {
+ id: "increment",
+ title: "Increment shortcut counter",
+ binding: { key: "k", mod: true, shift: true },
+ run: runs.increment,
+ });
+ await contribute("buzz.terminal", {
+ id: "toggle",
+ title: "Toggle channel terminal",
+ binding: { key: "j", mod: true },
+ run: runs.terminal,
+ });
+ return {
+ runs,
+ bindings,
+ shortcuts,
+ plugins: catalog([
+ info("example.counter", "Shortcut counter"),
+ info("buzz.terminal", "Terminal"),
+ ]),
+ setActive(id: string, active: boolean) {
+ if (active) inactive.delete(id);
+ else inactive.add(id);
+ for (const listener of statusListeners) listener();
+ },
+ /** Dispatch to the window listener from a non-editable target. */
+ press: (key: string, init: KeyboardEventInit = {}) =>
+ fireEvent.keyDown(document.body, { key, ctrlKey: true, ...init }),
+ async dispose() {
+ bindings.dispose();
+ await root.fiber.dispose();
+ },
+ };
+}
+const row = (title: string) => screen.getByRole("article", { name: title });
+const change = (title: string) =>
+ screen.getByRole("button", { name: `Change shortcut for ${title}` });
+const capture = (title: string) =>
+ screen.getByRole("textbox", { name: `New shortcut for ${title}` });
+
+it("lists live host and plugin shortcuts grouped by owner, searchable, and follows plugin state", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ const groups = screen
+ .getAllByRole("heading", { level: 2 })
+ .map((heading) => heading.textContent);
+ expect(groups).toEqual([
+ "Shortcuts",
+ "Buzz",
+ "Shortcut counter",
+ "Terminal",
+ ]);
+ expect(
+ screen
+ .getAllByRole("article")
+ .map(
+ (article) =>
+ within(article).getByRole("heading", { level: 3 }).textContent,
+ ),
+ ).toEqual([
+ "Increase text size",
+ "Open Settings",
+ "Search Buzz",
+ "Increment shortcut counter",
+ "Toggle channel terminal",
+ ]);
+ // Chips show the first alias with glyphs; the label reads as words.
+ const grow = row("Increase text size");
+ expect(within(grow).getByText("Command =")).toHaveClass("sr-only");
+ const chips = grow.querySelectorAll("kbd kbd");
+ expect([...chips].map((chip) => chip.textContent)).toEqual(["⌘", "="]);
+ expect(chips[0]?.parentElement).toHaveAttribute("aria-hidden", "true");
+ expect(grow.querySelector("[data-design-pass='pending']")).toHaveAttribute(
+ "data-binding",
+ "⌘=",
+ );
+ expect(
+ within(row("Increment shortcut counter")).getByText("Shift Command K"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Modified")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Reset all shortcuts" }),
+ ).toBeDisabled();
+
+ const search = screen.getByRole("searchbox", { name: "Search shortcuts" });
+ await user.type(search, "counter");
+ expect(screen.getAllByRole("article")).toHaveLength(1);
+ expect(row("Increment shortcut counter")).toBeInTheDocument();
+ await user.clear(search);
+ await user.type(search, "⌘J");
+ expect(screen.getAllByRole("article")).toHaveLength(1);
+ expect(row("Toggle channel terminal")).toBeInTheDocument();
+ await user.clear(search);
+ await user.type(search, "command ,");
+ expect(screen.getAllByRole("article")).toHaveLength(1);
+ expect(row("Open Settings")).toBeInTheDocument();
+ await user.clear(search);
+ await user.type(search, "zzz");
+ expect(screen.queryAllByRole("article")).toHaveLength(0);
+ expect(screen.getByText("No matching shortcuts.")).toBeInTheDocument();
+ await user.clear(search);
+
+ act(() => h.setActive("buzz.terminal", false));
+ expect(
+ screen.queryByRole("heading", { name: "Terminal" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getAllByRole("article")).toHaveLength(4);
+ act(() => h.setActive("buzz.terminal", true));
+ expect(row("Toggle channel terminal")).toBeInTheDocument();
+ } finally {
+ await h.dispose();
+ }
+});
+
+it("captures a chord, refuses conflicts and bare keys, applies overrides to the dispatcher, and resets", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ const title = "Increment shortcut counter";
+ expect(within(row(title)).getByText("Control Shift K")).toBeInTheDocument();
+ await user.click(change(title));
+ const input = capture(title);
+ expect(input).toHaveFocus();
+ expect(input).toHaveValue("Press a shortcut…");
+ expect(input).toHaveAttribute("data-state", "listening");
+ expect(input).toHaveAttribute("data-design-pass", "pending");
+ expect(
+ screen.getByRole("button", { name: `Cancel changing ${title}` }),
+ ).toBeInTheDocument();
+ // IME, bare modifiers and unmodified keys never become bindings.
+ fireEvent.keyDown(input, { key: "k", ctrlKey: true, isComposing: true });
+ fireEvent.keyDown(input, { key: "k", ctrlKey: true, keyCode: 229 });
+ fireEvent.keyDown(input, { key: "Control", ctrlKey: true });
+ fireEvent.keyDown(input, { key: "Shift", shiftKey: true });
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(capture(title)).toBe(input);
+ fireEvent.keyDown(input, { key: "k", shiftKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Include Control or Alt so ordinary typing keeps working.",
+ );
+ fireEvent.keyDown(input, { key: "k", metaKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "The Windows/Command key isn’t used for shortcuts on this device.",
+ );
+ // Conflicts are reported by title and owner; nothing is saved or fired.
+ fireEvent.keyDown(input, { key: "k", ctrlKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Ctrl+K is already used by Search Buzz (Buzz).",
+ );
+ expect(screen.getByRole("alert")).toHaveClass("text-danger");
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ screen.getByRole("alert").id,
+ );
+ fireEvent.keyDown(input, { key: "j", ctrlKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Ctrl+J is already used by Toggle channel terminal (Terminal).",
+ );
+ expect(h.runs.search).not.toHaveBeenCalled();
+ expect(h.runs.terminal).not.toHaveBeenCalled();
+ expect(h.bindings.snapshot().overrides).toEqual({});
+ expect(capture(title)).toBe(input);
+
+ await user.keyboard("{Control>}{Shift>}u{/Shift}{/Control}");
+ expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(within(row(title)).getByText("Modified")).toBeInTheDocument();
+ expect(within(row(title)).getByText("Control Shift U")).toBeInTheDocument();
+ expect(change(title)).toHaveFocus();
+ expect(h.bindings.snapshot().overrides).toEqual({
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ });
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(
+ JSON.stringify({
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ }),
+ );
+ expect(h.press("k", { shiftKey: true })).toBe(true);
+ expect(h.runs.increment).not.toHaveBeenCalled();
+ expect(h.press("u", { shiftKey: true })).toBe(false);
+ expect(h.runs.increment).toHaveBeenCalledTimes(1);
+ await user.click(
+ screen.getByRole("button", { name: `Reset shortcut for ${title}` }),
+ );
+ expect(within(row(title)).queryByText("Modified")).toBeNull();
+ expect(h.bindings.resolve("example.counter/increment")).toBeUndefined();
+ h.press("k", { shiftKey: true });
+ expect(h.runs.increment).toHaveBeenCalledTimes(2);
+
+ // Escape and blur cancel without saving; focus returns to the row action.
+ await user.click(change("Open Settings"));
+ fireEvent.keyDown(capture("Open Settings"), { key: "Escape" });
+ expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull();
+ expect(change("Open Settings")).toHaveFocus();
+ await user.click(change("Open Settings"));
+ await user.click(document.body);
+ expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull();
+ expect(h.bindings.snapshot().overrides).toEqual({});
+
+ // Editor-local chords save with a warning; the freed chord becomes usable.
+ await user.click(change("Open Settings"));
+ fireEvent.keyDown(capture("Open Settings"), { key: "z", ctrlKey: true });
+ const warning = within(row("Open Settings")).getByRole("alert");
+ expect(warning).toHaveTextContent("message editor handles Ctrl+Z");
+ expect(warning).toHaveClass("text-warning");
+ expect(h.bindings.resolve("settings")).toEqual({ key: "z", mod: true });
+ await user.click(change("Toggle channel terminal"));
+ fireEvent.keyDown(capture("Toggle channel terminal"), {
+ key: ",",
+ ctrlKey: true,
+ });
+ expect(
+ within(row("Toggle channel terminal")).getByText("Modified"),
+ ).toBeInTheDocument();
+ h.press(",");
+ expect(h.runs.terminal).toHaveBeenCalledTimes(1);
+ expect(h.runs.settings).not.toHaveBeenCalled();
+ // Choosing a shortcut's own default clears its override instead.
+ await user.click(change("Toggle channel terminal"));
+ fireEvent.keyDown(capture("Toggle channel terminal"), {
+ key: "j",
+ ctrlKey: true,
+ });
+ expect(within(row("Toggle channel terminal")).queryByText("Modified")).toBe(
+ null,
+ );
+ expect(h.bindings.resolve("buzz.terminal/toggle")).toBeUndefined();
+
+ // Rebinding an alias set replaces the whole set; reset restores it.
+ await user.click(change("Increase text size"));
+ fireEvent.keyDown(capture("Increase text size"), {
+ key: "=",
+ ctrlKey: true,
+ altKey: true,
+ });
+ expect(
+ within(row("Increase text size")).getByText("Control Alt ="),
+ ).toBeInTheDocument();
+ h.press("=");
+ h.press("+", { shiftKey: true });
+ expect(h.runs.grow).not.toHaveBeenCalled();
+ h.press("=", { altKey: true });
+ expect(h.runs.grow).toHaveBeenCalledTimes(1);
+ const resetAll = screen.getByRole("button", {
+ name: "Reset all shortcuts",
+ });
+ expect(resetAll).toBeEnabled();
+ await user.click(resetAll);
+ expect(screen.queryByText("Modified")).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(resetAll).toBeDisabled();
+ expect(h.bindings.snapshot().overrides).toEqual({});
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBeNull();
+ h.press("+", { shiftKey: true });
+ expect(h.runs.grow).toHaveBeenCalledTimes(2);
+ h.press(",");
+ expect(h.runs.settings).toHaveBeenCalledTimes(1);
+ } finally {
+ await h.dispose();
+ }
+});
+
+it("keeps a change active when saving fails and offers a retry", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ const write = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("quota");
+ });
+ await user.click(change("Toggle channel terminal"));
+ fireEvent.keyDown(capture("Toggle channel terminal"), {
+ key: "u",
+ ctrlKey: true,
+ });
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("could not be saved on this device");
+ expect(
+ within(row("Toggle channel terminal")).getByText("Modified"),
+ ).toBeInTheDocument();
+ h.press("u");
+ expect(h.runs.terminal).toHaveBeenCalledTimes(1);
+ write.mockRestore();
+ await user.click(
+ within(alert).getByRole("button", { name: "Retry saving shortcuts" }),
+ );
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(
+ JSON.stringify({ "buzz.terminal/toggle": { key: "u", mod: true } }),
+ );
+ } finally {
+ await h.dispose();
+ }
+});
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
new file mode 100644
index 000000000..d6693afa1
--- /dev/null
+++ b/src/app/ShortcutSettings.tsx
@@ -0,0 +1,374 @@
+import {
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import { Button } from "../shared/design-system/ui/Button";
+import { NavigationSection } from "../shared/design-system/ui/NavigationSection";
+import { SearchField } from "../shared/design-system/ui/SearchField";
+import { sameBinding, type KeyBinding } from "../features/shortcuts/bindings";
+import { formatBinding, isApplePlatform } from "../features/shortcuts/format";
+import { KeyCombo } from "../features/shortcuts/KeyCombo";
+import {
+ KeyCaptureControl,
+ type CapturedChord,
+} from "../features/shortcuts/KeyCaptureControl";
+import type { ShortcutBindings } from "../features/shortcuts/preferences";
+import type { Shortcut, ShortcutsService } from "../features/shortcuts/service";
+import type { PluginManager } from "../plugins/manager";
+
+type Row = Readonly<{
+ key: string;
+ title: string;
+ owner: string;
+ defaults: readonly KeyBinding[];
+ override: KeyBinding | undefined;
+ effective: readonly KeyBinding[];
+}>;
+type Group = Readonly<{ id: string; label: string; rows: readonly Row[] }>;
+type Notice = Readonly<{
+ key: string;
+ tone: "error" | "warning";
+ message: string;
+}>;
+
+/** Chords the message editor handles locally before the window dispatcher. */
+const EDITOR_CHORDS: readonly KeyBinding[] = [
+ { key: "z", mod: true },
+ { key: "z", mod: true, shift: true },
+ { key: "y", mod: true },
+ { key: "Home", mod: true },
+ { key: "End", mod: true },
+];
+const bindingsOf = (shortcut: Shortcut): readonly KeyBinding[] =>
+ "key" in shortcut.binding ? [shortcut.binding] : shortcut.binding;
+const byTitle = (a: Row, b: Row) =>
+ a.title.localeCompare(b.title) || a.key.localeCompare(b.key);
+
+/**
+ * Lists every host binding and every active plugin contribution from the live
+ * dispatcher registry, so the page cannot drift from what actually fires.
+ * Overrides are keyed by the registry's own identity: host id or `pluginId/id`.
+ */
+export function ShortcutSettings({
+ shortcuts,
+ bindings,
+ plugins,
+ apple = isApplePlatform(navigator.platform),
+}: {
+ shortcuts: ShortcutsService;
+ bindings: ShortcutBindings;
+ /** Display names for plugin groups come from the catalog. */
+ plugins: Pick;
+ apple?: boolean;
+}) {
+ const host = useSyncExternalStore(
+ shortcuts.hostSubscribe,
+ shortcuts.hostSnapshot,
+ );
+ const contributed = useSyncExternalStore(
+ shortcuts.subscribe,
+ shortcuts.snapshot,
+ );
+ const { overrides, error } = useSyncExternalStore(
+ bindings.subscribe,
+ bindings.snapshot,
+ );
+ const { configuration } = useSyncExternalStore(
+ plugins.subscribe,
+ plugins.snapshot,
+ );
+ const [query, setQuery] = useState("");
+ const [editing, setEditing] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const pluginName = (id: string) =>
+ (configuration.status === "ready"
+ ? configuration.catalog.plugins.find(
+ (plugin) => plugin.manifest.id === id,
+ )?.manifest.name
+ : undefined) ?? id;
+ const row = (key: string, shortcut: Shortcut, owner: string): Row => {
+ const defaults = bindingsOf(shortcut);
+ const override = overrides[key];
+ return {
+ key,
+ title: shortcut.title,
+ owner,
+ defaults,
+ override,
+ effective: override ? [override] : defaults,
+ };
+ };
+ const groups: Group[] = [
+ {
+ id: "buzz",
+ label: "Buzz",
+ rows: host.map((shortcut) => row(shortcut.id, shortcut, "Buzz")),
+ },
+ ...[...new Set(contributed.map((shortcut) => shortcut.pluginId))]
+ .map((pluginId) => {
+ const label = pluginName(pluginId);
+ return {
+ id: pluginId,
+ label,
+ rows: contributed
+ .filter((shortcut) => shortcut.pluginId === pluginId)
+ .map((shortcut) => row(shortcut.key, shortcut, label)),
+ };
+ })
+ .sort((a, b) => a.label.localeCompare(b.label)),
+ ]
+ .map((group) => ({ ...group, rows: [...group.rows].sort(byTitle) }))
+ .filter((group) => group.rows.length);
+ const rows = groups.flatMap((group) => group.rows);
+ const needle = query.trim().toLowerCase();
+ const visible = needle
+ ? groups
+ .map((group) => ({
+ ...group,
+ rows: group.rows.filter((row) =>
+ [
+ row.title,
+ row.owner,
+ ...row.effective.flatMap((binding) => {
+ const { text, label } = formatBinding(binding, apple);
+ return [text, label];
+ }),
+ ].some((text) => text.toLowerCase().includes(needle)),
+ ),
+ }))
+ .filter((group) => group.rows.length)
+ : groups;
+ const modified = Object.keys(overrides).length > 0;
+
+ const start = (key: string) => {
+ setEditing(key);
+ setNotice(null);
+ };
+ const cancel = (key: string) => {
+ setEditing((current) => (current === key ? null : current));
+ setNotice((current) =>
+ current?.key === key && current.tone === "error" ? null : current,
+ );
+ };
+ // Conflicts are decided here, not in the dispatcher: host chords always win
+ // there, and plugin ties resolve by sorted key, neither of which is a choice.
+ const capture = (target: Row, { binding, otherPrimary }: CapturedChord) => {
+ const chord = formatBinding(binding, apple).text;
+ const refuse = (message: string) =>
+ setNotice({ key: target.key, tone: "error", message });
+ if (otherPrimary)
+ return refuse(
+ apple
+ ? "Control isn’t used for shortcuts on this device. Try Command or Option."
+ : "The Windows/Command key isn’t used for shortcuts on this device. Try Control or Alt.",
+ );
+ if (!binding.mod && !binding.alt)
+ return refuse(
+ `Include ${apple ? "Command or Option" : "Control or Alt"} so ordinary typing keeps working.`,
+ );
+ if (binding.key === "Dead" || binding.key === "Unidentified")
+ return refuse("That key can’t be used for a shortcut. Try another.");
+ const conflict = rows.find(
+ (row) =>
+ row.key !== target.key &&
+ row.effective.some((current) => sameBinding(current, binding)),
+ );
+ if (conflict)
+ return refuse(
+ `${chord} is already used by ${conflict.title} (${conflict.owner}).`,
+ );
+ const isDefault =
+ target.defaults.length === 1 &&
+ target.defaults.every((current) => sameBinding(current, binding));
+ bindings.set(target.key, isDefault ? null : binding);
+ setEditing(null);
+ setNotice(
+ EDITOR_CHORDS.some((current) => sameBinding(current, binding))
+ ? {
+ key: target.key,
+ tone: "warning",
+ message: `Saved. The message editor handles ${chord} itself while you are typing, so it wins there.`,
+ }
+ : null,
+ );
+ };
+
+ return (
+
+
+ Shortcuts
+
+
+
+ Every shortcut from Buzz and your enabled plugins. Choose Change, then
+ press the new keys; Escape cancels. Saved on this device.
+
+
+ {visible.length ? (
+
+ {visible.map((group) => (
+
+
+ {group.rows.map((row) => (
+ start(row.key)}
+ onCancel={() => cancel(row.key)}
+ onCapture={(chord) => capture(row, chord)}
+ onReset={() => {
+ cancel(row.key);
+ setNotice(null);
+ bindings.set(row.key, null);
+ }}
+ />
+ ))}
+
+
+ ))}
+
+ ) : (
+
+ {rows.length
+ ? "No matching shortcuts."
+ : "No shortcuts are available yet."}
+
+ )}
+
+ {
+ setEditing(null);
+ setNotice(null);
+ bindings.reset();
+ }}
+ >
+ Reset all shortcuts
+
+
+ {error && (
+
+
{error}
+
+
+ Retry saving shortcuts
+
+
+
+ )}
+
+
+ );
+}
+
+function ShortcutRow({
+ row,
+ apple,
+ listening,
+ notice,
+ onStart,
+ onCancel,
+ onCapture,
+ onReset,
+}: {
+ row: Row;
+ apple: boolean;
+ listening: boolean;
+ notice: Notice | null;
+ onStart: () => void;
+ onCancel: () => void;
+ onCapture: (chord: CapturedChord) => void;
+ onReset: () => void;
+}) {
+ const titleId = useId();
+ const noticeId = useId();
+ const change = useRef(null);
+ const wasListening = useRef(listening);
+ // Return focus to the row's action when the capture control goes away
+ // without the person having moved focus somewhere else.
+ useEffect(() => {
+ if (
+ wasListening.current &&
+ !listening &&
+ document.activeElement === document.body
+ )
+ change.current?.focus();
+ wasListening.current = listening;
+ }, [listening]);
+ const primary = row.effective[0];
+ return (
+
+
+
+ {row.title}
+
+ {row.override && (
+
Modified
+ )}
+ {notice && (
+
+ {notice.message}
+
+ )}
+
+
+ {listening ? (
+
+ ) : (
+ primary &&
+ )}
+ event.preventDefault()}
+ onClick={listening ? onCancel : onStart}
+ >
+ {listening ? "Cancel" : "Change"}
+
+ {row.override && !listening && (
+
+ Reset
+
+ )}
+
+
+ );
+}
diff --git a/src/app/navigation.ts b/src/app/navigation.ts
index ddf13e0df..7350898a4 100644
--- a/src/app/navigation.ts
+++ b/src/app/navigation.ts
@@ -78,9 +78,14 @@ export function useAppNavigation(services: AppServices) {
if (
target.kind === "settings" &&
target.section &&
- !["profile", "plugins", "appearance", "messages", "notifications"].includes(
- target.section,
- ) &&
+ ![
+ "profile",
+ "plugins",
+ "appearance",
+ "shortcuts",
+ "messages",
+ "notifications",
+ ].includes(target.section) &&
!(developerMode && target.section === "developer")
)
failure = "unavailable";
diff --git a/src/app/services.ts b/src/app/services.ts
index c36a3f944..c4bbc5afb 100644
--- a/src/app/services.ts
+++ b/src/app/services.ts
@@ -9,6 +9,7 @@ import {
notificationAuthorized,
} from "../features/notifications/messages";
import { ShortcutsService } from "../features/shortcuts/service";
+import { createShortcutBindings } from "../features/shortcuts/preferences";
import { ConversationService } from "../features/conversation/service";
import { createAppearance } from "../shared/theme/service";
import { createCommunities } from "../features/communities/service";
@@ -21,6 +22,7 @@ import { withTimeout } from "../plugins/timeout";
export function createServices() {
const appearance = createAppearance();
+ const shortcutBindings = createShortcutBindings();
const ctx = new Context();
const plugins = createPluginManager(ctx, {
bundled: bundledPlugins,
@@ -28,7 +30,7 @@ export function createServices() {
const agentControl = provideAgentControl(ctx);
const navigationHost = provideNavigation(ctx);
const navigation = navigationHost.navigation;
- const shortcuts = new ShortcutsService(ctx);
+ const shortcuts = new ShortcutsService(ctx, undefined, shortcutBindings);
const pages = new PagesService(ctx);
const panels = new PanelsService(ctx);
const conversation = new ConversationService(ctx);
@@ -57,6 +59,7 @@ export function createServices() {
navigation,
navigationHost,
shortcuts,
+ shortcutBindings,
conversation,
pages,
panels,
@@ -66,6 +69,7 @@ export function createServices() {
appearance,
dispose() {
appearance.dispose();
+ shortcutBindings.dispose();
// Start root cancellation without waiting for plugin-owned cleanup. Cordis
// starts sibling effects independently; the runtime still owns replacement
// barriers. A timeout reports incomplete cleanup, never successful disposal.
diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx
index a65554d9d..56837243e 100644
--- a/src/app/shell/PageSearch.tsx
+++ b/src/app/shell/PageSearch.tsx
@@ -12,6 +12,12 @@ import { Button } from "../../shared/design-system/ui/Button";
import type { RegisteredPage } from "../../features/pages/service";
import { communityDestination } from "../../features/communities/destination";
import { useRelayConnection } from "../../features/relay/react";
+import type { KeyBinding } from "../../features/shortcuts/bindings";
+import {
+ formatBinding,
+ isApplePlatform,
+} from "../../features/shortcuts/format";
+import type { ShortcutBindingsSnapshot } from "../../features/shortcuts/preferences";
import type { AppServices } from "../services";
import {
orderPages,
@@ -27,8 +33,13 @@ import { SearchResults } from "./SearchResults";
export type SearchServices = Pick<
AppServices,
- "communities" | "shortcuts" | "navigation"
+ "communities" | "shortcuts" | "shortcutBindings" | "navigation"
>;
+const SEARCH_ID = "global-search";
+const SEARCH_BINDING: KeyBinding = { key: "k", mod: true };
+const NO_OVERRIDES: ShortcutBindingsSnapshot = { overrides: {}, error: null };
+const noSubscribe = () => () => {};
+const noOverrides = () => NO_OVERRIDES;
export function PageSearch({
pages,
@@ -55,14 +66,19 @@ export function PageSearch({
useEffect(
() =>
services?.shortcuts.registerHost({
- id: "global-search",
+ id: SEARCH_ID,
title: "Search Buzz",
- binding: { key: "k", mod: true },
+ binding: SEARCH_BINDING,
allowInEditable: true,
run: begin,
}),
[services, begin],
);
+ // The hint follows the person's rebind, derived from the same binding object.
+ const { overrides } = useSyncExternalStore(
+ services?.shortcutBindings.subscribe ?? noSubscribe,
+ services?.shortcutBindings.snapshot ?? noOverrides,
+ );
const destinations: SearchDestination[] = [
{ key: "home", ...shellPresentation.home },
...orderPages(pages).map((page) => ({
@@ -82,7 +98,10 @@ export function PageSearch({
setOpen(false);
},
}));
- const shortcut = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
+ const shortcut = formatBinding(
+ overrides[SEARCH_ID] ?? SEARCH_BINDING,
+ isApplePlatform(navigator.platform),
+ ).text;
return (
<>
{
// No unusable launcher or reserved shortcut in a browser-only host.
if (!nativeBridge.available) return;
@@ -24,7 +29,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
ctx.shortcuts.register({
id: "toggle",
title: "Toggle channel terminal",
- binding: { key: "j", mod: true },
+ binding: TOGGLE_BINDING,
allowInEditable: true,
when: () => !!binding?.available(),
run: () => binding?.toggle(),
@@ -49,7 +54,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
variant={pressed ? "tint" : "ghost"}
icon={ }
aria-label="Toggle channel terminal"
- title="Terminal (Cmd/Ctrl+J)"
+ title={`Terminal (${formatBinding(TOGGLE_BINDING, isApplePlatform(navigator.platform)).text})`}
aria-pressed={pressed}
onClick={(event) => {
event.currentTarget.focus();
diff --git a/src/features/shortcuts/KeyCaptureControl.module.css b/src/features/shortcuts/KeyCaptureControl.module.css
new file mode 100644
index 000000000..e85f15a1b
--- /dev/null
+++ b/src/features/shortcuts/KeyCaptureControl.module.css
@@ -0,0 +1,12 @@
+/* DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system. */
+.listening {
+ min-width: var(--size-navigator);
+ min-height: var(--size-control-sm);
+ border: 1px solid var(--border-standard);
+ border-radius: var(--radius-control);
+ padding: 0 var(--space-3);
+ background: var(--surface-inset);
+ color: var(--text-standard);
+ caret-color: transparent;
+ cursor: default;
+}
diff --git a/src/features/shortcuts/KeyCaptureControl.tsx b/src/features/shortcuts/KeyCaptureControl.tsx
new file mode 100644
index 000000000..75ebba6f2
--- /dev/null
+++ b/src/features/shortcuts/KeyCaptureControl.tsx
@@ -0,0 +1,87 @@
+// DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system.
+import { useEffect, useRef } from "react";
+import type { KeyBinding } from "./bindings";
+import styles from "./KeyCaptureControl.module.css";
+
+export type CapturedChord = Readonly<{
+ binding: KeyBinding;
+ /** Control on Apple platforms or Command elsewhere: the dispatcher never matches it. */
+ otherPrimary: boolean;
+}>;
+
+const MODIFIER_KEYS = new Set([
+ "Alt",
+ "AltGraph",
+ "CapsLock",
+ "Control",
+ "Fn",
+ "FnLock",
+ "Hyper",
+ "Meta",
+ "NumLock",
+ "OS",
+ "ScrollLock",
+ "Shift",
+ "Super",
+ "Symbol",
+ "SymbolLock",
+]);
+
+/**
+ * Focused listening control. Its own keydown handler consumes the event before
+ * the window dispatcher sees it, so the shortcut being rebound never fires.
+ * Escape and losing focus cancel; the owner decides whether a chord is accepted.
+ */
+export function KeyCaptureControl({
+ apple,
+ label,
+ describedBy,
+ onCapture,
+ onCancel,
+}: {
+ apple: boolean;
+ label: string;
+ describedBy?: string | undefined;
+ onCapture: (chord: CapturedChord) => void;
+ onCancel: () => void;
+}) {
+ const input = useRef(null);
+ useEffect(() => {
+ input.current?.focus();
+ }, []);
+ return (
+ {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (MODIFIER_KEYS.has(event.key)) return;
+ const held = event.metaKey || event.ctrlKey || event.altKey;
+ if (event.key === "Escape" && !held && !event.shiftKey) {
+ onCancel();
+ return;
+ }
+ const mod = apple ? event.metaKey : event.ctrlKey;
+ onCapture({
+ binding: {
+ key: event.key.length === 1 ? event.key.toLowerCase() : event.key,
+ ...(mod && { mod }),
+ ...(event.shiftKey && { shift: true }),
+ ...(event.altKey && { alt: true }),
+ },
+ otherPrimary: apple ? event.ctrlKey : event.metaKey,
+ });
+ }}
+ />
+ );
+}
diff --git a/src/features/shortcuts/KeyCombo.module.css b/src/features/shortcuts/KeyCombo.module.css
new file mode 100644
index 000000000..bc1b371e4
--- /dev/null
+++ b/src/features/shortcuts/KeyCombo.module.css
@@ -0,0 +1,19 @@
+/* DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system. */
+.combo,
+.keys {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+.key {
+ display: inline-flex;
+ min-width: var(--space-6);
+ height: var(--space-6);
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border-standard);
+ border-radius: var(--radius-chip);
+ padding: 0 var(--space-chip-inset);
+ background: var(--surface-inset);
+ color: var(--text-standard);
+}
diff --git a/src/features/shortcuts/KeyCombo.tsx b/src/features/shortcuts/KeyCombo.tsx
new file mode 100644
index 000000000..17f6f0505
--- /dev/null
+++ b/src/features/shortcuts/KeyCombo.tsx
@@ -0,0 +1,31 @@
+// DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system.
+import type { KeyBinding } from "./bindings";
+import { formatBinding } from "./format";
+import styles from "./KeyCombo.module.css";
+
+/** A chord as key chips. Nested kbd marks each key inside the chord. */
+export function KeyCombo({
+ binding,
+ apple,
+}: {
+ binding: KeyBinding;
+ apple: boolean;
+}) {
+ const { parts, text, label } = formatBinding(binding, apple);
+ return (
+
+ {label}
+
+ {parts.map((part) => (
+
+ {part}
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/shortcuts/bindings.ts b/src/features/shortcuts/bindings.ts
index d9380f8e5..cf19202d6 100644
--- a/src/features/shortcuts/bindings.ts
+++ b/src/features/shortcuts/bindings.ts
@@ -36,18 +36,7 @@ export function normalizeShortcut(shortcut: Shortcut): Shortcut {
const bindings = Array.isArray(shortcut.binding)
? shortcut.binding
: [shortcut.binding];
- if (
- !bindings.length ||
- bindings.some(
- (binding) =>
- !binding ||
- typeof binding.key !== "string" ||
- binding.key.length === 0 ||
- [binding.mod, binding.shift, binding.alt].some(
- (value) => value !== undefined && typeof value !== "boolean",
- ),
- )
- )
+ if (!bindings.length || !bindings.every(isKeyBinding))
throw new Error("Invalid shortcut binding");
return Object.freeze({
...shortcut,
@@ -57,14 +46,35 @@ export function normalizeShortcut(shortcut: Shortcut): Shortcut {
});
}
+/** Shape check shared by registration and stored user overrides. */
+export function isKeyBinding(value: unknown): value is KeyBinding {
+ if (!value || typeof value !== "object") return false;
+ const binding = value as Record;
+ return (
+ typeof binding.key === "string" &&
+ binding.key.length > 0 &&
+ [binding.mod, binding.shift, binding.alt].every(
+ (flag) => flag === undefined || typeof flag === "boolean",
+ )
+ );
+}
+
+/** Same chord under the dispatcher's rules: case-insensitive key, exact modifiers. */
+export function sameBinding(a: KeyBinding, b: KeyBinding) {
+ return (
+ a.key.toLowerCase() === b.key.toLowerCase() &&
+ !!a.mod === !!b.mod &&
+ !!a.shift === !!b.shift &&
+ !!a.alt === !!b.alt
+ );
+}
+
export function matches(
- shortcut: Shortcut,
+ candidates: KeyBinding | readonly KeyBinding[],
event: KeyboardEvent,
apple: boolean,
) {
- const bindings = Array.isArray(shortcut.binding)
- ? shortcut.binding
- : [shortcut.binding];
+ const bindings = Array.isArray(candidates) ? candidates : [candidates];
return bindings.some(
(binding) =>
binding.key.toLowerCase() === event.key.toLowerCase() &&
diff --git a/src/features/shortcuts/format.test.ts b/src/features/shortcuts/format.test.ts
new file mode 100644
index 000000000..b69872b5e
--- /dev/null
+++ b/src/features/shortcuts/format.test.ts
@@ -0,0 +1,66 @@
+import { expect, it } from "vitest";
+import { formatBinding, isApplePlatform } from "./format";
+
+it("uses the dispatcher's platform test", () => {
+ expect(isApplePlatform("MacIntel")).toBe(true);
+ expect(isApplePlatform("iPhone")).toBe(true);
+ expect(isApplePlatform("Linux x86_64")).toBe(false);
+ expect(isApplePlatform("Win32")).toBe(false);
+ expect(isApplePlatform("")).toBe(false);
+});
+
+it("renders glyphs in Control, Option, Shift, Command order on Apple platforms", () => {
+ expect(formatBinding({ key: "k", mod: true, shift: true }, true)).toEqual({
+ parts: ["⇧", "⌘", "K"],
+ text: "⇧⌘K",
+ label: "Shift Command K",
+ });
+ expect(
+ formatBinding({ key: "k", mod: true, shift: true, alt: true }, true).text,
+ ).toBe("⌥⇧⌘K");
+ expect(formatBinding({ key: ",", mod: true }, true)).toEqual({
+ parts: ["⌘", ","],
+ text: "⌘,",
+ label: "Command ,",
+ });
+ expect(formatBinding({ key: "ArrowLeft", alt: true }, true)).toEqual({
+ parts: ["⌥", "←"],
+ text: "⌥←",
+ label: "Option Left Arrow",
+ });
+});
+
+it("renders words joined by plus signs elsewhere", () => {
+ expect(formatBinding({ key: "k", mod: true, shift: true }, false)).toEqual({
+ parts: ["Ctrl", "Shift", "K"],
+ text: "Ctrl+Shift+K",
+ label: "Control Shift K",
+ });
+ expect(
+ formatBinding({ key: "k", mod: true, shift: true, alt: true }, false).text,
+ ).toBe("Ctrl+Alt+Shift+K");
+ expect(formatBinding({ key: "ArrowRight", alt: true }, false)).toEqual({
+ parts: ["Alt", "→"],
+ text: "Alt+→",
+ label: "Alt Right Arrow",
+ });
+});
+
+it("names special keys readably and leaves symbols alone", () => {
+ expect(formatBinding({ key: " ", mod: true }, true).text).toBe("⌘Space");
+ expect(formatBinding({ key: "Enter", mod: true }, false).text).toBe(
+ "Ctrl+Enter",
+ );
+ expect(formatBinding({ key: "Escape" }, true).text).toBe("Escape");
+ expect(formatBinding({ key: "PageUp", mod: true }, true)).toEqual({
+ parts: ["⌘", "PageUp"],
+ text: "⌘PageUp",
+ label: "Command Page Up",
+ });
+ expect(formatBinding({ key: "=", mod: true }, false).text).toBe("Ctrl+=");
+ expect(formatBinding({ key: "+", mod: true, shift: true }, true).text).toBe(
+ "⇧⌘+",
+ );
+ // Registered keys are matched case-insensitively; display is always uppercase.
+ expect(formatBinding({ key: "K", mod: true }, true).label).toBe("Command K");
+});
diff --git a/src/features/shortcuts/format.ts b/src/features/shortcuts/format.ts
new file mode 100644
index 000000000..f7e111bc0
--- /dev/null
+++ b/src/features/shortcuts/format.ts
@@ -0,0 +1,65 @@
+import type { KeyBinding } from "./bindings";
+
+/** The dispatcher's platform test: `mod` is Command here and Control elsewhere. */
+export const isApplePlatform = (platform: string) =>
+ /Mac|iPhone|iPad/.test(platform);
+
+export type FormattedBinding = Readonly<{
+ /** One entry per key chip, e.g. ["⇧", "⌘", "K"] or ["Ctrl", "Shift", "K"]. */
+ parts: readonly string[];
+ /** Compact display text, e.g. "⇧⌘K" or "Ctrl+Shift+K". */
+ text: string;
+ /** Plain words for assistive technology, e.g. "Shift Command K". */
+ label: string;
+}>;
+
+type Words = readonly [display: string, spoken: string];
+const SPECIAL_KEYS: Readonly> = {
+ " ": ["Space", "Space"],
+ enter: ["Enter", "Enter"],
+ escape: ["Escape", "Escape"],
+ tab: ["Tab", "Tab"],
+ backspace: ["Backspace", "Backspace"],
+ delete: ["Delete", "Delete"],
+ home: ["Home", "Home"],
+ end: ["End", "End"],
+ pageup: ["PageUp", "Page Up"],
+ pagedown: ["PageDown", "Page Down"],
+ arrowup: ["↑", "Up Arrow"],
+ arrowdown: ["↓", "Down Arrow"],
+ arrowleft: ["←", "Left Arrow"],
+ arrowright: ["→", "Right Arrow"],
+};
+// Apple follows the platform convention Control, Option, Shift, Command.
+const APPLE_MODIFIERS: Readonly> = {
+ alt: ["⌥", "Option"],
+ shift: ["⇧", "Shift"],
+ mod: ["⌘", "Command"],
+};
+const OTHER_MODIFIERS: Readonly> = {
+ mod: ["Ctrl", "Control"],
+ alt: ["Alt", "Alt"],
+ shift: ["Shift", "Shift"],
+};
+
+/** One formatter for every chord in the UI, so hints cannot drift from the dispatcher. */
+export function formatBinding(
+ binding: KeyBinding,
+ apple: boolean,
+): FormattedBinding {
+ const special = SPECIAL_KEYS[binding.key.toLowerCase()];
+ const key: Words = special ?? [
+ binding.key.length === 1 ? binding.key.toUpperCase() : binding.key,
+ binding.key.length === 1 ? binding.key.toUpperCase() : binding.key,
+ ];
+ const table = apple ? APPLE_MODIFIERS : OTHER_MODIFIERS;
+ const modifiers = (Object.keys(table) as (keyof typeof table)[])
+ .filter((flag) => binding[flag])
+ .map((flag) => table[flag]);
+ const parts = [...modifiers, key];
+ return Object.freeze({
+ parts: Object.freeze(parts.map(([display]) => display)),
+ text: parts.map(([display]) => display).join(apple ? "" : "+"),
+ label: parts.map(([, spoken]) => spoken).join(" "),
+ });
+}
diff --git a/src/features/shortcuts/preferences.test.ts b/src/features/shortcuts/preferences.test.ts
new file mode 100644
index 000000000..f346a5fd5
--- /dev/null
+++ b/src/features/shortcuts/preferences.test.ts
@@ -0,0 +1,141 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ createShortcutBindings,
+ parseOverrides,
+ SHORTCUT_BINDINGS_KEY,
+} from "./preferences";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ localStorage.clear();
+});
+
+it("keeps well-formed entries and drops only malformed ones", () => {
+ expect(
+ parseOverrides({
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ settings: { key: ";", mod: true, alt: false, extra: "ignored" },
+ empty: { key: "" },
+ text: "not a binding",
+ flags: { key: "k", mod: "yes" },
+ "": { key: "k", mod: true },
+ }),
+ ).toEqual({
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ settings: { key: ";", mod: true, alt: false },
+ });
+ for (const raw of [null, [], "text", 42])
+ expect(parseOverrides(raw)).toEqual({});
+});
+
+it("restores what it can, reports unreadable storage, and resolves at lookup time", () => {
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ broken: { key: 5 },
+ }),
+ );
+ const bindings = createShortcutBindings(window);
+ expect(bindings.snapshot()).toEqual({
+ overrides: {
+ "example.counter/increment": { key: "u", mod: true, shift: true },
+ },
+ error: null,
+ });
+ expect(bindings.resolve("example.counter/increment")).toEqual({
+ key: "u",
+ mod: true,
+ shift: true,
+ });
+ expect(bindings.resolve("broken")).toBeUndefined();
+ expect(bindings.resolve("missing")).toBeUndefined();
+ bindings.dispose();
+ localStorage.setItem(SHORTCUT_BINDINGS_KEY, "{not json");
+ const unreadable = createShortcutBindings(window);
+ expect(unreadable.snapshot().overrides).toEqual({});
+ expect(unreadable.snapshot().error).toContain("could not be restored");
+ unreadable.set("settings", { key: ";", mod: true });
+ expect(unreadable.snapshot().error).toBeNull();
+ expect(JSON.parse(localStorage.getItem(SHORTCUT_BINDINGS_KEY) ?? "")).toEqual(
+ { settings: { key: ";", mod: true } },
+ );
+ unreadable.dispose();
+});
+
+it("applies changes in memory when saving fails, retries, and clears storage when empty", () => {
+ const bindings = createShortcutBindings(window);
+ const listener = vi.fn();
+ bindings.subscribe(listener);
+ const write = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("storage unavailable");
+ });
+ bindings.set("settings", { key: ";", mod: true });
+ expect(bindings.resolve("settings")).toEqual({ key: ";", mod: true });
+ expect(bindings.snapshot().error).toContain("could not be saved");
+ expect(listener).toHaveBeenCalledTimes(1);
+ write.mockRestore();
+ bindings.retry();
+ expect(bindings.snapshot().error).toBeNull();
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(
+ JSON.stringify({ settings: { key: ";", mod: true } }),
+ );
+ bindings.set("other", { key: "o", alt: true });
+ bindings.set("settings", null);
+ expect(bindings.snapshot().overrides).toEqual({
+ other: { key: "o", alt: true },
+ });
+ expect(() => bindings.set("bad", { key: "" })).toThrow("Invalid");
+ bindings.reset();
+ expect(bindings.snapshot().overrides).toEqual({});
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBeNull();
+ bindings.dispose();
+ bindings.set("settings", { key: ";", mod: true });
+ expect(bindings.snapshot().overrides).toEqual({});
+});
+
+it("re-reads on same-origin storage events and ignores unrelated keys", () => {
+ const bindings = createShortcutBindings(window);
+ const listener = vi.fn();
+ bindings.subscribe(listener);
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({ settings: { key: ";", mod: true } }),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "buzz-appearance.v1",
+ storageArea: localStorage,
+ }),
+ );
+ expect(listener).not.toHaveBeenCalled();
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: SHORTCUT_BINDINGS_KEY,
+ storageArea: localStorage,
+ }),
+ );
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(bindings.resolve("settings")).toEqual({ key: ";", mod: true });
+ localStorage.clear();
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: null, storageArea: localStorage }),
+ );
+ expect(bindings.snapshot().overrides).toEqual({});
+ bindings.dispose();
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({ settings: { key: ";", mod: true } }),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: SHORTCUT_BINDINGS_KEY,
+ storageArea: localStorage,
+ }),
+ );
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(bindings.snapshot().overrides).toEqual({});
+});
diff --git a/src/features/shortcuts/preferences.ts b/src/features/shortcuts/preferences.ts
new file mode 100644
index 000000000..a1fef604c
--- /dev/null
+++ b/src/features/shortcuts/preferences.ts
@@ -0,0 +1,128 @@
+/** Host-owned, device-local shortcut rebinds. Never depends on plugin/relay readiness. */
+import { isKeyBinding, type KeyBinding } from "./bindings";
+
+export const SHORTCUT_BINDINGS_KEY = "buzz-shortcut-bindings.v1";
+export type ShortcutOverrides = Readonly>;
+export interface ShortcutBindingsSnapshot {
+ readonly overrides: ShortcutOverrides;
+ readonly error: string | null;
+}
+
+const RESTORE_ERROR =
+ "Custom shortcuts could not be restored. Defaults are active; change a shortcut to try saving again.";
+const SAVE_ERROR =
+ "Your shortcuts are active, but could not be saved on this device. Try again.";
+
+/** Keeps every well-formed entry and drops only malformed ones. */
+export function parseOverrides(raw: unknown): ShortcutOverrides {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ const entries = Object.entries(raw as Record).filter(
+ ([key, binding]) => key.length > 0 && isKeyBinding(binding),
+ ) as [string, KeyBinding][];
+ return Object.freeze(
+ Object.fromEntries(
+ entries.map(([key, { key: name, mod, shift, alt }]) => [
+ key,
+ Object.freeze({
+ key: name,
+ ...(mod !== undefined && { mod }),
+ ...(shift !== undefined && { shift }),
+ ...(alt !== undefined && { alt }),
+ }),
+ ]),
+ ),
+ );
+}
+
+export function createShortcutBindings(
+ host: Window | undefined = typeof window === "undefined" ? undefined : window,
+) {
+ let state: ShortcutBindingsSnapshot = { overrides: {}, error: null };
+ let disposed = false;
+ const listeners = new Set<() => void>();
+ const notify = () => {
+ for (const listener of listeners) listener();
+ };
+ const restore = () => {
+ try {
+ const raw = host?.localStorage.getItem(SHORTCUT_BINDINGS_KEY);
+ state = {
+ overrides: raw ? parseOverrides(JSON.parse(raw)) : {},
+ error: null,
+ };
+ } catch {
+ state = { overrides: {}, error: RESTORE_ERROR };
+ }
+ notify();
+ };
+ // The change applies in memory even when the save fails; the page offers a retry.
+ const commit = (overrides: ShortcutOverrides) => {
+ let error: string | null = null;
+ try {
+ if (!host) throw new Error("No browser storage");
+ if (Object.keys(overrides).length)
+ host.localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify(overrides),
+ );
+ else host.localStorage.removeItem(SHORTCUT_BINDINGS_KEY);
+ } catch {
+ error = SAVE_ERROR;
+ }
+ state = { overrides, error };
+ notify();
+ };
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== SHORTCUT_BINDINGS_KEY && event.key !== null) return;
+ // Re-read the current value: an older queued event must not undo a newer save.
+ try {
+ if (event.storageArea !== host?.localStorage) return;
+ } catch {
+ /* restore reports the failure */
+ }
+ restore();
+ };
+ restore();
+ host?.addEventListener("storage", onStorage);
+ return {
+ snapshot: () => state,
+ subscribe(listener: () => void) {
+ if (disposed) return () => {};
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ /** Dispatcher lookup at match time; undefined means the registered default. */
+ resolve: (key: string) => state.overrides[key],
+ set(key: string, binding: KeyBinding | null) {
+ if (disposed) return;
+ if (binding !== null && !isKeyBinding(binding))
+ throw new Error("Invalid shortcut binding");
+ const rest = Object.fromEntries(
+ Object.entries(state.overrides).filter(([entry]) => entry !== key),
+ );
+ commit(
+ Object.freeze(
+ binding === null
+ ? rest
+ : { ...rest, [key]: Object.freeze({ ...binding }) },
+ ),
+ );
+ },
+ reset() {
+ if (!disposed) commit(Object.freeze({}));
+ },
+ /** Re-attempt persisting what is already active after a failed save. */
+ retry() {
+ if (!disposed) commit(state.overrides);
+ },
+ dispose() {
+ disposed = true;
+ host?.removeEventListener("storage", onStorage);
+ listeners.clear();
+ },
+ };
+}
+
+export type ShortcutBindings = ReturnType;
diff --git a/src/features/shortcuts/service.test.ts b/src/features/shortcuts/service.test.ts
index c562341c6..50e04c36d 100644
--- a/src/features/shortcuts/service.test.ts
+++ b/src/features/shortcuts/service.test.ts
@@ -3,7 +3,7 @@ import { expect, it, vi } from "vitest";
import { ShortcutsService } from "./service";
import { PluginRuntime } from "../../plugins/runtime";
import type { PluginInfo } from "../../plugins/types";
-import type { Shortcut } from "./bindings";
+import type { KeyBinding, Shortcut } from "./bindings";
function browser(apple = true) {
const listeners = new Set<(event: KeyboardEvent) => void>();
@@ -290,3 +290,95 @@ it("accepts the logical Space key but not an empty binding", async () => {
await root.fiber.dispose();
}
});
+
+it("resolves user overrides at match time for plugin and host bindings", async () => {
+ const b = browser(),
+ root = new Context(),
+ calls: string[] = [];
+ const overrides = new Map();
+ const runtime = new PluginRuntime(root, async (info) => ({
+ inject: ["shortcuts"],
+ apply(ctx) {
+ ctx.shortcuts.register(
+ shortcut(() => {
+ calls.push(`${info.manifest.id}:${info.revision}`);
+ }),
+ );
+ },
+ }));
+ const service = new ShortcutsService(root, b.host, {
+ resolve: (key) => overrides.get(key),
+ });
+ try {
+ runtime.reconcile([plugin("a")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(1));
+ // No override: the registered default applies.
+ b.key();
+ expect(calls).toEqual(["a:one"]);
+ overrides.set("a/action", { key: "p", mod: true, shift: true });
+ expect(b.key().defaultPrevented).toBe(false);
+ expect(b.key("p", { shiftKey: true }).defaultPrevented).toBe(true);
+ expect(calls).toEqual(["a:one", "a:one"]);
+ // The plugin never re-registers; the override follows the stable key
+ // across disable, re-enable and replacement.
+ runtime.reconcile([]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(0));
+ expect(b.key("p", { shiftKey: true }).defaultPrevented).toBe(false);
+ runtime.reconcile([plugin("a", "two")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(1));
+ b.key("p", { shiftKey: true });
+ expect(calls.at(-1)).toBe("a:two");
+ expect(service.snapshot()[0]?.binding).toEqual([{ key: "k", mod: true }]);
+ // A host binding's overridden chord is the reserved one.
+ const hostRun = vi.fn();
+ const remove = service.registerHost({
+ ...shortcut(hostRun),
+ id: "host-action",
+ binding: { key: "h", mod: true },
+ });
+ overrides.set("host-action", { key: "p", mod: true, shift: true });
+ b.key("p", { shiftKey: true });
+ expect(hostRun).toHaveBeenCalledTimes(1);
+ expect(calls).toHaveLength(3);
+ expect(b.key("h").defaultPrevented).toBe(false);
+ overrides.delete("host-action");
+ b.key("h");
+ expect(hostRun).toHaveBeenCalledTimes(2);
+ b.key("p", { shiftKey: true });
+ expect(calls).toHaveLength(4);
+ remove();
+ } finally {
+ await runtime.dispose();
+ await root.fiber.dispose();
+ }
+});
+
+it("exposes host bindings to the host with change notifications", async () => {
+ const b = browser(),
+ ctx = new Context();
+ ctx.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const service = new ShortcutsService(ctx, b.host);
+ const listener = vi.fn();
+ const stop = service.hostSubscribe(listener);
+ expect(service.hostSnapshot()).toEqual([]);
+ const remove = service.registerHost(shortcut());
+ expect(listener).toHaveBeenCalledTimes(1);
+ const registered = service.hostSnapshot();
+ expect(registered.map((entry) => entry.id)).toEqual(["action"]);
+ expect(registered[0]?.binding).toEqual([{ key: "k", mod: true }]);
+ expect(service.snapshot()).toEqual([]);
+ remove();
+ remove();
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(service.hostSnapshot()).toEqual([]);
+ expect(service.hostSnapshot()).not.toBe(registered);
+ stop();
+ service.registerHost(shortcut());
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(service.hostSnapshot()).toHaveLength(1);
+ await ctx.fiber.dispose();
+ expect(service.hostSnapshot()).toEqual([]);
+});
diff --git a/src/features/shortcuts/service.ts b/src/features/shortcuts/service.ts
index 78f492b85..06466b4ce 100644
--- a/src/features/shortcuts/service.ts
+++ b/src/features/shortcuts/service.ts
@@ -8,6 +8,7 @@ import {
inEditable,
matches,
normalizeShortcut,
+ type KeyBinding,
type Shortcut,
} from "./bindings";
export type { KeyBinding, Shortcut } from "./bindings";
@@ -17,6 +18,10 @@ export type Shortcuts = {
snapshot(): readonly RegisteredShortcut[];
subscribe(listener: () => void): () => void;
};
+/** Device-local user rebinds, keyed by host id or namespaced contribution key. */
+export type BindingOverrides = {
+ resolve(key: string): KeyBinding | undefined;
+};
declare module "@deepseek-ai/cordis" {
interface Context {
shortcuts: Shortcuts;
@@ -26,15 +31,22 @@ declare module "@deepseek-ai/cordis" {
export class ShortcutsService extends Service implements Shortcuts {
private readonly contributions;
private readonly hostBindings = new Map();
+ private readonly hostListeners = new Set<() => void>();
+ private hostReady: readonly Shortcut[] = Object.freeze([]);
constructor(
ctx: Context,
host: Window | undefined = typeof window === "undefined"
? undefined
: window,
+ overrides?: BindingOverrides,
) {
super(ctx, "shortcuts");
this.contributions = createContributions(ctx);
const apple = /Mac|iPhone|iPad/.test(host?.navigator.platform ?? "");
+ // The effective chord is resolved at match time, so a rebind never requires
+ // re-registration and survives plugin disable/enable.
+ const bindingsFor = (shortcut: Shortcut, key: string) =>
+ overrides?.resolve(key) ?? shortcut.binding;
const dispatch = (event: KeyboardEvent) => {
if (
event.defaultPrevented ||
@@ -64,7 +76,7 @@ export class ShortcutsService extends Service implements Shortcuts {
};
// Host chords are reserved even while unavailable (e.g. Settings behind a modal).
const reserved = [...this.hostBindings.values()].find((shortcut) =>
- matches(shortcut, event, apple),
+ matches(bindingsFor(shortcut, shortcut.id), event, apple),
);
const shortcut = reserved
? eligible(reserved)
@@ -74,7 +86,8 @@ export class ShortcutsService extends Service implements Shortcuts {
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
.find(
(shortcut) =>
- matches(shortcut, event, apple) && eligible(shortcut),
+ matches(bindingsFor(shortcut, shortcut.key), event, apple) &&
+ eligible(shortcut),
);
if (!shortcut) return;
event.preventDefault();
@@ -92,6 +105,7 @@ export class ShortcutsService extends Service implements Shortcuts {
return () => {
host?.removeEventListener("keydown", dispatch);
this.hostBindings.clear();
+ this.publishHost();
};
});
}
@@ -106,9 +120,23 @@ export class ShortcutsService extends Service implements Shortcuts {
if (this.hostBindings.has(entry.id))
throw new Error(`Host shortcut already registered: ${entry.id}`);
this.hostBindings.set(entry.id, entry);
+ this.publishHost();
+ return () => {
+ if (this.hostBindings.get(entry.id) !== entry) return;
+ this.hostBindings.delete(entry.id);
+ this.publishHost();
+ };
+ }
+ /** Host-only read path for Settings; plugins keep seeing only contributions. */
+ hostSnapshot = () => this.hostReady;
+ hostSubscribe = (listener: () => void) => {
+ this.hostListeners.add(listener);
return () => {
- if (this.hostBindings.get(entry.id) === entry)
- this.hostBindings.delete(entry.id);
+ this.hostListeners.delete(listener);
};
+ };
+ private publishHost() {
+ this.hostReady = Object.freeze([...this.hostBindings.values()]);
+ for (const listener of this.hostListeners) listener();
}
}
diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts
index dda96b276..89e0aed8c 100644
--- a/src/shared/design-system/icons/index.ts
+++ b/src/shared/design-system/icons/index.ts
@@ -99,6 +99,8 @@ import { HashIcon as PhosphorHashIcon } from "@phosphor-icons/react/dist/csr/Has
export const HashIcon = defineIcon("phosphor", PhosphorHashIcon);
import { HouseIcon as PhosphorHouseIcon } from "@phosphor-icons/react/dist/csr/House";
export const HouseIcon = defineIcon("phosphor", PhosphorHouseIcon);
+import { KeyboardIcon as PhosphorKeyboardIcon } from "@phosphor-icons/react/dist/csr/Keyboard";
+export const KeyboardIcon = defineIcon("phosphor", PhosphorKeyboardIcon);
import { LinkIcon as PhosphorLinkIcon } from "@phosphor-icons/react/dist/csr/Link";
export const LinkIcon = defineIcon("phosphor", PhosphorLinkIcon);
import { LockIcon as PhosphorLockIcon } from "@phosphor-icons/react/dist/csr/Lock";
diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs
index 9c883bb1a..47f49aa10 100644
--- a/tests/browser/settings.spec.mjs
+++ b/tests/browser/settings.spec.mjs
@@ -229,6 +229,10 @@ test("avatar Settings access dismisses cleanly and exposes Profile and Plugins",
sections.getByRole("button", { name: "Appearance", exact: true }),
).toBeFocused();
await tab();
+ await expect(
+ sections.getByRole("button", { name: "Shortcuts", exact: true }),
+ ).toBeFocused();
+ await tab();
await expect(messages).toBeFocused();
await tab();
await expect(
diff --git a/tests/browser/shortcuts.spec.mjs b/tests/browser/shortcuts.spec.mjs
index 2b0fd6917..20063405a 100644
--- a/tests/browser/shortcuts.spec.mjs
+++ b/tests/browser/shortcuts.spec.mjs
@@ -245,3 +245,64 @@ test("a shadow-root modal blocks Settings and plugin bindings but allows text zo
await page.keyboard.press(`${modifier}+Shift+k`);
await expect(page.getByRole("status")).toHaveText("Shortcut count: 1");
});
+
+test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflicts, persists and resets", async ({
+ page,
+ app,
+}) => {
+ await page.goto(app.origin);
+ const modifier = await mod(page);
+ const title = "Increment shortcut counter";
+ await button(page, "Shortcut counter").first().click();
+ const count = page.getByRole("status");
+ await expect(count).toHaveText("Shortcut count: 0");
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ await page.keyboard.press(`${modifier}+,`);
+ await button(page, "Shortcuts").click();
+ const region = page.getByRole("region", { name: "Shortcuts", exact: true });
+ await expect(
+ region.getByRole("heading", { name: "Shortcut counter", exact: true }),
+ ).toBeVisible();
+ const row = region.getByRole("article", { name: title });
+ await expect(row.getByText(/Shift (Command|Control) K/)).toBeAttached();
+ await button(row, `Change shortcut for ${title}`).click();
+ const listening = page.getByRole("textbox", {
+ name: `New shortcut for ${title}`,
+ });
+ await expect(listening).toBeFocused();
+ // The chord being listened for goes to the capture control, not the dispatcher.
+ await page.keyboard.press(`${modifier}+k`);
+ await expect(row.getByRole("alert")).toContainText("Search Buzz");
+ await expect(
+ page.getByRole("dialog", { name: "Search Buzz", includeHidden: true }),
+ ).toHaveCount(0);
+ await expect(listening).toBeFocused();
+ await page.keyboard.press(`${modifier}+Shift+u`);
+ await expect(listening).toHaveCount(0);
+ await expect(row.getByText("Modified")).toBeVisible();
+ await expect(row.getByText(/Shift (Command|Control) U/)).toBeAttached();
+ await expect(button(row, `Reset shortcut for ${title}`)).toBeVisible();
+ await expect(button(row, `Change shortcut for ${title}`)).toBeFocused();
+ await button(page, "Shortcut counter").first().click();
+ await expect(count).toHaveText("Shortcut count: 1");
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await page.keyboard.press(`${modifier}+Shift+u`);
+ await expect(count).toHaveText("Shortcut count: 2");
+ await page.reload();
+ await button(page, "Shortcut counter").first().click();
+ await expect(count).toHaveText("Shortcut count: 0");
+ await page.keyboard.press(`${modifier}+Shift+u`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ await page.keyboard.press(`${modifier}+,`);
+ await button(page, "Shortcuts").click();
+ await button(row, `Reset shortcut for ${title}`).click();
+ await expect(row.getByText("Modified")).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Reset all shortcuts", exact: true }),
+ ).toBeDisabled();
+ await button(page, "Shortcut counter").first().click();
+ await page.keyboard.press(`${modifier}+Shift+u`);
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 2");
+});
From a51af1a7cf75e6be4981702955e6630de420288b Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 13:33:36 +1000
Subject: [PATCH 02/10] fix(shortcuts): harden override lookup and conflict
rules from review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the review of the rebindable shortcut settings.
Override lookup can no longer throw or allocate on keydown. The store keeps
overrides on a null-prototype record and prebuilds one frozen alias list per
key in a Map, so resolve() returns readonly KeyBinding[] and a host id such as
"constructor" yields a binding or nothing, never Object.prototype's function.
matches() takes only an alias array; the dispatcher treats anything that is
not a nonempty binding list as the registered default, so a malformed store
entry or resolver cannot stop dispatch. normalizeShortcut now returns
NormalizedShortcut (binding always an array), which the registries use; the
plugin-facing Shortcuts/RegisteredShortcut types are unchanged.
Capture and conflict rules: Escape cancels whatever modifiers are held instead
of being refused or saved; the editor-shadowed warning list now covers
mod+Shift+Y and mod+Shift+Home/End; copy, cut, paste and select-all chords are
refused because a match would preventDefault them everywhere, and close-window
and quit chords are refused in the desktop (Tauri) build. Rows whose effective
chord another listed shortcut also answers to show a monochrome text-subtle
"Also used by …" line, surfacing conflicts that arise after capture when a
plugin is re-enabled or installed. Per-row Reset returns focus to that row's
Change button. The host group id is namespaced ("host", "plugin:") so a
plugin with manifest id "buzz" cannot collide.
Tests cover the prototype-named host id and misbehaving resolver in the
dispatcher, the store's inherited-property immunity and lookup identity,
Escape with modifiers, Dead/Unidentified refusal, the clipboard and desktop
refusals, the widened editor warnings, the shared-chord marker, focus after
Reset, the group id collision, an Option/Shift chord captured on Apple
platforms pinning the composed-character behaviour, and the search trigger
hint following a rebind. Docs gain a Known limitations note describing the
Option-chord composed-character behaviour and the intended event.code fix.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/design-system.md | 4 +-
docs/plugin-architecture.md | 21 +-
src/app/ShortcutSettings.test.tsx | 243 ++++++++++++++++++-
src/app/ShortcutSettings.tsx | 106 ++++++--
src/app/shell/PageSearch.test.tsx | 49 +++-
src/features/shortcuts/KeyCaptureControl.tsx | 8 +-
src/features/shortcuts/bindings.ts | 15 +-
src/features/shortcuts/preferences.test.ts | 41 +++-
src/features/shortcuts/preferences.ts | 85 ++++---
src/features/shortcuts/service.test.ts | 64 ++++-
src/features/shortcuts/service.ts | 21 +-
11 files changed, 570 insertions(+), 87 deletions(-)
diff --git a/docs/design-system.md b/docs/design-system.md
index 797b64ba0..927e31160 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -122,7 +122,9 @@ dispatcher, grouped by owner, with search, per-row Change/Reset and Reset all. I
is built from existing components (`SearchField`, `Button`, `NavigationSection`,
the Plugins-list row pattern) and `formatBinding`, which renders chords as glyphs
in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words
-elsewhere (Ctrl+Shift+K), with a plain-words accessible label. Two pieces are
+elsewhere (Ctrl+Shift+K), with a plain-words accessible label. A row whose chord
+another listed shortcut also answers to carries a plain "Also used by …" line in
+subtle text, no colour. Two pieces are
provisional and await a design pass: the key-combo `` chip
(`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control
(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 51f89bad4..8839454f6 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -530,8 +530,25 @@ an override; the override follows the plugin across disable, re-enable and
replacement, and an override whose owner is no longer installed is ignored rather
than deleted. Rebinding replaces an alias set with the single chosen chord; reset
restores every alias. Host chords stay reserved: the page refuses to assign a chord
-that another listed shortcut already uses, host or plugin, and warns when a chord
-is one the message editor handles locally. `formatBinding` in
+that another listed shortcut already uses, host or plugin, refuses the copy, cut,
+paste and select-all chords (and close-window/quit in the desktop build) because a
+match would prevent their default everywhere, and warns when a chord is one the
+message editor handles locally. A conflict can still appear after capture, for
+example when a plugin that was disabled at the time is re-enabled with the same
+default or a new plugin ships one; the dispatcher then resolves it silently, so
+each affected row shows an "Also used by …" line naming the others. A malformed
+stored override falls back to the registered default rather than stopping
+dispatch. `formatBinding` in
`features/shortcuts/format.ts` renders any `KeyBinding` for the current platform;
plugins that print their own hint (the bundled terminal does) show their registered
default because overrides are host state.
+
+Known limitations. Capture and matching both use the logical `KeyboardEvent.key`.
+On macOS an Option chord reports the composed character, so Option+K is stored
+and shown as `⌥˚`, and Shift+digit chords store the punctuation (`!` rather than
+`1`). This is internally consistent, so the binding fires, but it depends on the
+active keyboard layout and the displayed chord can differ from the keys pressed.
+The intended fix is to match Alt/Option chords on the physical `event.code` in
+both the capture control and the dispatcher's `matches`, which is a coordinated
+change to the plugin-facing matching rules and is deliberately not part of the
+Settings page.
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 49f33a1bb..0171d8207 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -123,6 +123,7 @@ async function harness() {
runs,
bindings,
shortcuts,
+ contribute,
plugins: catalog([
info("example.counter", "Shortcut counter"),
info("buzz.terminal", "Terminal"),
@@ -267,6 +268,21 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
expect(screen.getByRole("alert")).toHaveTextContent(
"The Windows/Command key isn’t used for shortcuts on this device.",
);
+ // Dead and unidentified keys, and the chords copy, paste and select all need.
+ fireEvent.keyDown(input, { key: "Dead", altKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "That key can’t be used for a shortcut. Try another.",
+ );
+ fireEvent.keyDown(input, { key: "Unidentified", ctrlKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "That key can’t be used for a shortcut. Try another.",
+ );
+ for (const key of ["c", "v", "x", "a"]) {
+ fireEvent.keyDown(input, { key, ctrlKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ `Ctrl+${key.toUpperCase()} is reserved for copy, cut, paste and select all. Try another.`,
+ );
+ }
// Conflicts are reported by title and owner; nothing is saved or fired.
fireEvent.keyDown(input, { key: "k", ctrlKey: true });
expect(screen.getByRole("alert")).toHaveTextContent(
@@ -308,15 +324,28 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
screen.getByRole("button", { name: `Reset shortcut for ${title}` }),
);
expect(within(row(title)).queryByText("Modified")).toBeNull();
+ // Reset unmounts its own button; keyboard focus stays anchored in the row.
+ expect(change(title)).toHaveFocus();
expect(h.bindings.resolve("example.counter/increment")).toBeUndefined();
h.press("k", { shiftKey: true });
expect(h.runs.increment).toHaveBeenCalledTimes(2);
- // Escape and blur cancel without saving; focus returns to the row action.
- await user.click(change("Open Settings"));
- fireEvent.keyDown(capture("Open Settings"), { key: "Escape" });
- expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull();
- expect(change("Open Settings")).toHaveFocus();
+ // Escape cancels whatever else is held, as does blur; nothing is saved and
+ // focus returns to the row action.
+ for (const held of [
+ {},
+ { shiftKey: true },
+ { ctrlKey: true },
+ { metaKey: true, altKey: true },
+ ]) {
+ await user.click(change("Open Settings"));
+ fireEvent.keyDown(capture("Open Settings"), { key: "Escape", ...held });
+ expect(
+ screen.queryByRole("textbox", { name: /New shortcut/ }),
+ ).toBeNull();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(change("Open Settings")).toHaveFocus();
+ }
await user.click(change("Open Settings"));
await user.click(document.body);
expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull();
@@ -328,7 +357,21 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
const warning = within(row("Open Settings")).getByRole("alert");
expect(warning).toHaveTextContent("message editor handles Ctrl+Z");
expect(warning).toHaveClass("text-warning");
- expect(h.bindings.resolve("settings")).toEqual({ key: "z", mod: true });
+ expect(h.bindings.resolve("settings")).toEqual([{ key: "z", mod: true }]);
+ for (const [key, chord] of [
+ ["y", "Ctrl+Shift+Y"],
+ ["End", "Ctrl+Shift+End"],
+ ] as const) {
+ await user.click(change("Open Settings"));
+ fireEvent.keyDown(capture("Open Settings"), {
+ key,
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ expect(within(row("Open Settings")).getByRole("alert")).toHaveTextContent(
+ `message editor handles ${chord}`,
+ );
+ }
await user.click(change("Toggle channel terminal"));
fireEvent.keyDown(capture("Toggle channel terminal"), {
key: ",",
@@ -425,3 +468,191 @@ it("keeps a change active when saving fails and offers a retry", async () => {
await h.dispose();
}
});
+
+it("stores Option and Shift chords as the composed key on Apple platforms (known limitation)", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ // macOS reports the composed character for Option chords: Option+K is "˚".
+ // Capture and dispatch both read event.key, so the chord fires but is
+ // layout-dependent and displays as the composed character.
+ const terminal = "Toggle channel terminal";
+ await user.click(change(terminal));
+ fireEvent.keyDown(capture(terminal), { key: "˚", altKey: true });
+ expect(within(row(terminal)).getByText("Option ˚")).toBeInTheDocument();
+ expect(
+ row(terminal).querySelector("[data-design-pass='pending']"),
+ ).toHaveAttribute("data-binding", "⌥˚");
+ expect(h.bindings.resolve("buzz.terminal/toggle")).toEqual([
+ { key: "˚", alt: true },
+ ]);
+ expect(fireEvent.keyDown(document.body, { key: "˚", altKey: true })).toBe(
+ false,
+ );
+ expect(h.runs.terminal).toHaveBeenCalledTimes(1);
+ // Shift+digit likewise stores the punctuation the layout produced.
+ const settings = "Open Settings";
+ await user.click(change(settings));
+ fireEvent.keyDown(capture(settings), {
+ key: "!",
+ metaKey: true,
+ shiftKey: true,
+ });
+ expect(
+ within(row(settings)).getByText("Shift Command !"),
+ ).toBeInTheDocument();
+ expect(h.bindings.resolve("settings")).toEqual([
+ { key: "!", mod: true, shift: true },
+ ]);
+ expect(h.press("!", { shiftKey: true })).toBe(false);
+ expect(h.runs.settings).toHaveBeenCalledTimes(1);
+ } finally {
+ await h.dispose();
+ }
+});
+
+it("refuses close and quit chords only in the desktop build", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ const desktop = render(
+ ,
+ );
+ const title = "Open Settings";
+ await user.click(change(title));
+ for (const key of ["q", "w"]) {
+ fireEvent.keyDown(capture(title), { key, metaKey: true });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ `⌘${key.toUpperCase()} is reserved for closing the window and quitting Buzz. Try another.`,
+ );
+ }
+ expect(h.bindings.snapshot().overrides).toEqual({});
+ desktop.unmount();
+ // A browser tab handles these before the page sees them, so nothing is lost.
+ render(
+ ,
+ );
+ await user.click(change(title));
+ fireEvent.keyDown(capture(title), { key: "w", metaKey: true });
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(within(row(title)).getByText("Command W")).toBeInTheDocument();
+ } finally {
+ await h.dispose();
+ }
+});
+
+it("marks rows that share an effective chord once a plugin is re-enabled, and clears the marker on reset", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ const counter = "Increment shortcut counter";
+ const terminal = "Toggle channel terminal";
+ expect(screen.queryByText(/Also used by/)).toBeNull();
+ // With the terminal disabled its chord is not listed, so Ctrl+J is accepted.
+ act(() => h.setActive("buzz.terminal", false));
+ await user.click(change(counter));
+ fireEvent.keyDown(capture(counter), { key: "j", ctrlKey: true });
+ expect(within(row(counter)).getByText("Control J")).toBeInTheDocument();
+ expect(screen.queryByText(/Also used by/)).toBeNull();
+ // Re-enabling brings the default back; the dispatcher would pick one silently.
+ act(() => h.setActive("buzz.terminal", true));
+ const marker = within(row(counter)).getByText(
+ "Also used by Toggle channel terminal (Terminal)",
+ );
+ expect(marker).toHaveClass("text-subtle");
+ expect(marker).not.toHaveClass("text-danger");
+ expect(marker).not.toHaveClass("text-warning");
+ expect(marker).not.toHaveAttribute("role");
+ expect(
+ within(row(terminal)).getByText(
+ "Also used by Increment shortcut counter (Shortcut counter)",
+ ),
+ ).toBeInTheDocument();
+ h.press("j");
+ expect(h.runs.terminal).toHaveBeenCalledTimes(1);
+ expect(h.runs.increment).not.toHaveBeenCalled();
+ // A host chord shared with a plugin is marked on both rows as well.
+ act(() => h.bindings.set("global-search", { key: "j", mod: true }));
+ expect(
+ within(row("Search Buzz")).getByText(/^Also used by/),
+ ).toHaveTextContent(
+ "Also used by Increment shortcut counter (Shortcut counter), Toggle channel terminal (Terminal)",
+ );
+ act(() => h.bindings.set("global-search", null));
+ expect(within(row("Search Buzz")).queryByText(/Also used by/)).toBeNull();
+ await user.click(
+ screen.getByRole("button", { name: `Reset shortcut for ${counter}` }),
+ );
+ expect(screen.queryByText(/Also used by/)).toBeNull();
+ expect(change(counter)).toHaveFocus();
+ } finally {
+ await h.dispose();
+ }
+});
+
+it("keeps the host group distinct from a plugin whose manifest id is buzz", async () => {
+ const h = await harness();
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ await h.contribute("buzz", {
+ id: "ping",
+ title: "Ping",
+ binding: { key: "p", mod: true },
+ run: vi.fn(),
+ });
+ render(
+ ,
+ );
+ expect(
+ screen
+ .getAllByRole("heading", { level: 2 })
+ .map((heading) => heading.textContent),
+ ).toEqual([
+ "Shortcuts",
+ "Buzz",
+ "Buzz plugin",
+ "Shortcut counter",
+ "Terminal",
+ ]);
+ expect(row("Ping")).toBeInTheDocument();
+ expect(error).not.toHaveBeenCalled();
+ } finally {
+ error.mockRestore();
+ await h.dispose();
+ }
+});
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
index d6693afa1..8deaa6142 100644
--- a/src/app/ShortcutSettings.tsx
+++ b/src/app/ShortcutSettings.tsx
@@ -5,10 +5,15 @@ import {
useState,
useSyncExternalStore,
} from "react";
+import { isTauri } from "@tauri-apps/api/core";
import { Button } from "../shared/design-system/ui/Button";
import { NavigationSection } from "../shared/design-system/ui/NavigationSection";
import { SearchField } from "../shared/design-system/ui/SearchField";
-import { sameBinding, type KeyBinding } from "../features/shortcuts/bindings";
+import {
+ sameBinding,
+ type KeyBinding,
+ type NormalizedShortcut,
+} from "../features/shortcuts/bindings";
import { formatBinding, isApplePlatform } from "../features/shortcuts/format";
import { KeyCombo } from "../features/shortcuts/KeyCombo";
import {
@@ -16,7 +21,7 @@ import {
type CapturedChord,
} from "../features/shortcuts/KeyCaptureControl";
import type { ShortcutBindings } from "../features/shortcuts/preferences";
-import type { Shortcut, ShortcutsService } from "../features/shortcuts/service";
+import type { ShortcutsService } from "../features/shortcuts/service";
import type { PluginManager } from "../plugins/manager";
type Row = Readonly<{
@@ -34,16 +39,31 @@ type Notice = Readonly<{
message: string;
}>;
-/** Chords the message editor handles locally before the window dispatcher. */
+/** Chords the message editor handles locally before the window dispatcher: undo/redo and line/document jumps. */
const EDITOR_CHORDS: readonly KeyBinding[] = [
{ key: "z", mod: true },
{ key: "z", mod: true, shift: true },
{ key: "y", mod: true },
+ { key: "y", mod: true, shift: true },
{ key: "Home", mod: true },
+ { key: "Home", mod: true, shift: true },
{ key: "End", mod: true },
+ { key: "End", mod: true, shift: true },
+];
+/** Copy, paste, cut and select all: a matching shortcut would preventDefault them everywhere. */
+const CLIPBOARD_CHORDS: readonly KeyBinding[] = [
+ { key: "c", mod: true },
+ { key: "v", mod: true },
+ { key: "x", mod: true },
+ { key: "a", mod: true },
+];
+/** Close window and quit: the desktop shell owns these, so they are refused there. */
+const DESKTOP_CHORDS: readonly KeyBinding[] = [
+ { key: "q", mod: true },
+ { key: "w", mod: true },
];
-const bindingsOf = (shortcut: Shortcut): readonly KeyBinding[] =>
- "key" in shortcut.binding ? [shortcut.binding] : shortcut.binding;
+const includes = (chords: readonly KeyBinding[], binding: KeyBinding) =>
+ chords.some((chord) => sameBinding(chord, binding));
const byTitle = (a: Row, b: Row) =>
a.title.localeCompare(b.title) || a.key.localeCompare(b.key);
@@ -57,12 +77,15 @@ export function ShortcutSettings({
bindings,
plugins,
apple = isApplePlatform(navigator.platform),
+ desktop = isTauri(),
}: {
shortcuts: ShortcutsService;
bindings: ShortcutBindings;
/** Display names for plugin groups come from the catalog. */
plugins: Pick;
apple?: boolean;
+ /** Packaged desktop build, where close/quit chords belong to the shell. */
+ desktop?: boolean;
}) {
const host = useSyncExternalStore(
shortcuts.hostSubscribe,
@@ -90,21 +113,26 @@ export function ShortcutSettings({
(plugin) => plugin.manifest.id === id,
)?.manifest.name
: undefined) ?? id;
- const row = (key: string, shortcut: Shortcut, owner: string): Row => {
- const defaults = bindingsOf(shortcut);
+ const row = (
+ key: string,
+ shortcut: NormalizedShortcut,
+ owner: string,
+ ): Row => {
const override = overrides[key];
return {
key,
title: shortcut.title,
owner,
- defaults,
+ defaults: shortcut.binding,
override,
- effective: override ? [override] : defaults,
+ effective: override ? [override] : shortcut.binding,
};
};
+ // Group ids are namespaced so a plugin whose manifest id is "buzz" or "host"
+ // cannot share a React key with the host group.
const groups: Group[] = [
{
- id: "buzz",
+ id: "host",
label: "Buzz",
rows: host.map((shortcut) => row(shortcut.id, shortcut, "Buzz")),
},
@@ -112,7 +140,7 @@ export function ShortcutSettings({
.map((pluginId) => {
const label = pluginName(pluginId);
return {
- id: pluginId,
+ id: `plugin:${pluginId}`,
label,
rows: contributed
.filter((shortcut) => shortcut.pluginId === pluginId)
@@ -124,6 +152,17 @@ export function ShortcutSettings({
.map((group) => ({ ...group, rows: [...group.rows].sort(byTitle) }))
.filter((group) => group.rows.length);
const rows = groups.flatMap((group) => group.rows);
+ // A chord can become shared after capture (a plugin enabled later, a new
+ // default in a release). The dispatcher then picks one silently, so each
+ // affected row names the others.
+ const sharedWith = (target: Row) =>
+ rows.filter(
+ (other) =>
+ other.key !== target.key &&
+ other.effective.some((binding) =>
+ target.effective.some((current) => sameBinding(current, binding)),
+ ),
+ );
const needle = query.trim().toLowerCase();
const visible = needle
? groups
@@ -160,6 +199,8 @@ export function ShortcutSettings({
const chord = formatBinding(binding, apple).text;
const refuse = (message: string) =>
setNotice({ key: target.key, tone: "error", message });
+ if (binding.key === "Dead" || binding.key === "Unidentified")
+ return refuse("That key can’t be used for a shortcut. Try another.");
if (otherPrimary)
return refuse(
apple
@@ -170,8 +211,14 @@ export function ShortcutSettings({
return refuse(
`Include ${apple ? "Command or Option" : "Control or Alt"} so ordinary typing keeps working.`,
);
- if (binding.key === "Dead" || binding.key === "Unidentified")
- return refuse("That key can’t be used for a shortcut. Try another.");
+ if (includes(CLIPBOARD_CHORDS, binding))
+ return refuse(
+ `${chord} is reserved for copy, cut, paste and select all. Try another.`,
+ );
+ if (desktop && includes(DESKTOP_CHORDS, binding))
+ return refuse(
+ `${chord} is reserved for closing the window and quitting Buzz. Try another.`,
+ );
const conflict = rows.find(
(row) =>
row.key !== target.key &&
@@ -187,7 +234,7 @@ export function ShortcutSettings({
bindings.set(target.key, isDefault ? null : binding);
setEditing(null);
setNotice(
- EDITOR_CHORDS.some((current) => sameBinding(current, binding))
+ includes(EDITOR_CHORDS, binding)
? {
key: target.key,
tone: "warning",
@@ -223,6 +270,7 @@ export function ShortcutSettings({
key={row.key}
row={row}
apple={apple}
+ sharedWith={sharedWith(row)}
listening={editing === row.key}
notice={notice?.key === row.key ? notice : null}
onStart={() => start(row.key)}
@@ -277,6 +325,7 @@ export function ShortcutSettings({
function ShortcutRow({
row,
apple,
+ sharedWith,
listening,
notice,
onStart,
@@ -286,6 +335,8 @@ function ShortcutRow({
}: {
row: Row;
apple: boolean;
+ /** Other listed shortcuts whose effective chord this row also answers to. */
+ sharedWith: readonly Row[];
listening: boolean;
notice: Notice | null;
onStart: () => void;
@@ -297,17 +348,19 @@ function ShortcutRow({
const noticeId = useId();
const change = useRef(null);
const wasListening = useRef(listening);
- // Return focus to the row's action when the capture control goes away
- // without the person having moved focus somewhere else.
+ const resetting = useRef(false);
+ // Return focus to the row's action when the capture control or the Reset
+ // button goes away without the person having moved focus somewhere else.
useEffect(() => {
+ const stopped = wasListening.current && !listening;
+ wasListening.current = listening;
if (
- wasListening.current &&
- !listening &&
+ (stopped || (resetting.current && !row.override)) &&
document.activeElement === document.body
)
change.current?.focus();
- wasListening.current = listening;
- }, [listening]);
+ resetting.current = false;
+ }, [listening, row.override]);
const primary = row.effective[0];
return (
Modified
)}
+ {sharedWith.length > 0 && (
+
+ Also used by{" "}
+ {sharedWith
+ .map((other) => `${other.title} (${other.owner})`)
+ .join(", ")}
+
+ )}
{notice && (
{
+ resetting.current = true;
+ onReset();
+ }}
>
Reset
diff --git a/src/app/shell/PageSearch.test.tsx b/src/app/shell/PageSearch.test.tsx
index d6409e5e6..9ca2b3697 100644
--- a/src/app/shell/PageSearch.test.tsx
+++ b/src/app/shell/PageSearch.test.tsx
@@ -1,6 +1,8 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
+import { Context } from "@deepseek-ai/cordis";
import {
+ act,
cleanup,
fireEvent,
render,
@@ -9,8 +11,10 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
-import { PageSearch } from "./PageSearch";
+import { PageSearch, type SearchServices } from "./PageSearch";
import type { RegisteredPage } from "../../features/pages/service";
+import { createShortcutBindings } from "../../features/shortcuts/preferences";
+import { ShortcutsService } from "../../features/shortcuts/service";
afterEach(() => {
cleanup();
@@ -149,3 +153,46 @@ it("invalidates selection when result identities change, even at the same index"
await user.keyboard("{ArrowUp}{Enter}");
expect(select).toHaveBeenCalledExactlyOnceWith("test/first");
});
+
+it("shows the live search shortcut in the trigger hint and follows a rebind", async () => {
+ Object.defineProperty(navigator, "platform", {
+ configurable: true,
+ value: "MacIntel",
+ });
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const bindings = createShortcutBindings(window);
+ const shortcuts = new ShortcutsService(root, window, bindings);
+ try {
+ // Only the shortcut services are read while the dialog is closed.
+ render(
+ ,
+ );
+ // The title renders as a focus/hover tooltip rather than a native attribute.
+ const user = userEvent.setup();
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Search Buzz" })).toHaveFocus();
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "Search Buzz (⌘K)",
+ );
+ act(() =>
+ bindings.set("global-search", { key: "p", mod: true, shift: true }),
+ );
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Search Buzz (⇧⌘P)");
+ act(() => bindings.reset());
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Search Buzz (⌘K)");
+ } finally {
+ Reflect.deleteProperty(navigator, "platform");
+ bindings.dispose();
+ await root.fiber.dispose();
+ }
+});
diff --git a/src/features/shortcuts/KeyCaptureControl.tsx b/src/features/shortcuts/KeyCaptureControl.tsx
index 75ebba6f2..8f6227da2 100644
--- a/src/features/shortcuts/KeyCaptureControl.tsx
+++ b/src/features/shortcuts/KeyCaptureControl.tsx
@@ -30,7 +30,8 @@ const MODIFIER_KEYS = new Set([
/**
* Focused listening control. Its own keydown handler consumes the event before
* the window dispatcher sees it, so the shortcut being rebound never fires.
- * Escape and losing focus cancel; the owner decides whether a chord is accepted.
+ * Escape (whatever else is held) and losing focus cancel; the owner decides
+ * whether a chord is accepted.
*/
export function KeyCaptureControl({
apple,
@@ -66,8 +67,9 @@ export function KeyCaptureControl({
event.preventDefault();
event.stopPropagation();
if (MODIFIER_KEYS.has(event.key)) return;
- const held = event.metaKey || event.ctrlKey || event.altKey;
- if (event.key === "Escape" && !held && !event.shiftKey) {
+ // Someone pressing Shift+Escape or Command+Escape is backing out, not
+ // choosing a binding, so Escape never reaches onCapture.
+ if (event.key === "Escape") {
onCancel();
return;
}
diff --git a/src/features/shortcuts/bindings.ts b/src/features/shortcuts/bindings.ts
index cf19202d6..60f30ad96 100644
--- a/src/features/shortcuts/bindings.ts
+++ b/src/features/shortcuts/bindings.ts
@@ -19,7 +19,11 @@ export type Shortcut = Readonly<{
repeat?: boolean;
}>;
-export function normalizeShortcut(shortcut: Shortcut): Shortcut {
+/** What the registries hold: aliases are always a frozen array, never a bare binding. */
+export type NormalizedShortcut = Shortcut &
+ Readonly<{ binding: readonly KeyBinding[] }>;
+
+export function normalizeShortcut(shortcut: Shortcut): NormalizedShortcut {
if (
!shortcut ||
typeof shortcut.id !== "string" ||
@@ -69,12 +73,17 @@ export function sameBinding(a: KeyBinding, b: KeyBinding) {
);
}
+/** Nonempty list of well-formed bindings: the shape stored overrides must take. */
+export function isBindingList(value: unknown): value is readonly KeyBinding[] {
+ return Array.isArray(value) && value.length > 0 && value.every(isKeyBinding);
+}
+
+/** Callers pass the registered alias array or a stored override; nothing is wrapped here. */
export function matches(
- candidates: KeyBinding | readonly KeyBinding[],
+ bindings: readonly KeyBinding[],
event: KeyboardEvent,
apple: boolean,
) {
- const bindings = Array.isArray(candidates) ? candidates : [candidates];
return bindings.some(
(binding) =>
binding.key.toLowerCase() === event.key.toLowerCase() &&
diff --git a/src/features/shortcuts/preferences.test.ts b/src/features/shortcuts/preferences.test.ts
index f346a5fd5..7e619c5b7 100644
--- a/src/features/shortcuts/preferences.test.ts
+++ b/src/features/shortcuts/preferences.test.ts
@@ -44,11 +44,17 @@ it("restores what it can, reports unreadable storage, and resolves at lookup tim
},
error: null,
});
- expect(bindings.resolve("example.counter/increment")).toEqual({
- key: "u",
- mod: true,
- shift: true,
- });
+ expect(bindings.resolve("example.counter/increment")).toEqual([
+ { key: "u", mod: true, shift: true },
+ ]);
+ // One frozen alias list per key, reused on every lookup: the dispatcher
+ // never wraps or allocates on keydown.
+ expect(bindings.resolve("example.counter/increment")).toBe(
+ bindings.resolve("example.counter/increment"),
+ );
+ expect(Object.isFrozen(bindings.resolve("example.counter/increment"))).toBe(
+ true,
+ );
expect(bindings.resolve("broken")).toBeUndefined();
expect(bindings.resolve("missing")).toBeUndefined();
bindings.dispose();
@@ -74,7 +80,7 @@ it("applies changes in memory when saving fails, retries, and clears storage whe
throw new Error("storage unavailable");
});
bindings.set("settings", { key: ";", mod: true });
- expect(bindings.resolve("settings")).toEqual({ key: ";", mod: true });
+ expect(bindings.resolve("settings")).toEqual([{ key: ";", mod: true }]);
expect(bindings.snapshot().error).toContain("could not be saved");
expect(listener).toHaveBeenCalledTimes(1);
write.mockRestore();
@@ -119,7 +125,7 @@ it("re-reads on same-origin storage events and ignores unrelated keys", () => {
}),
);
expect(listener).toHaveBeenCalledTimes(1);
- expect(bindings.resolve("settings")).toEqual({ key: ";", mod: true });
+ expect(bindings.resolve("settings")).toEqual([{ key: ";", mod: true }]);
localStorage.clear();
window.dispatchEvent(
new StorageEvent("storage", { key: null, storageArea: localStorage }),
@@ -139,3 +145,24 @@ it("re-reads on same-origin storage events and ignores unrelated keys", () => {
expect(listener).toHaveBeenCalledTimes(2);
expect(bindings.snapshot().overrides).toEqual({});
});
+
+it("never hands out an inherited property: a prototype-named key is a stored binding or nothing", () => {
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({ constructor: { key: "p", mod: true } }),
+ );
+ const bindings = createShortcutBindings(window);
+ expect(bindings.resolve("constructor")).toEqual([{ key: "p", mod: true }]);
+ for (const key of ["toString", "hasOwnProperty", "valueOf", "__proto__"]) {
+ expect(bindings.resolve(key)).toBeUndefined();
+ expect(key in bindings.snapshot().overrides).toBe(false);
+ }
+ bindings.set("constructor", null);
+ expect(bindings.resolve("constructor")).toBeUndefined();
+ expect("constructor" in bindings.snapshot().overrides).toBe(false);
+ expect("constructor" in parseOverrides({ settings: { key: "k" } })).toBe(
+ false,
+ );
+ expect("constructor" in parseOverrides(null)).toBe(false);
+ bindings.dispose();
+});
diff --git a/src/features/shortcuts/preferences.ts b/src/features/shortcuts/preferences.ts
index a1fef604c..b7c5300c4 100644
--- a/src/features/shortcuts/preferences.ts
+++ b/src/features/shortcuts/preferences.ts
@@ -13,23 +13,35 @@ const RESTORE_ERROR =
const SAVE_ERROR =
"Your shortcuts are active, but could not be saved on this device. Try again.";
+const freezeBinding = ({ key, mod, shift, alt }: KeyBinding): KeyBinding =>
+ Object.freeze({
+ key,
+ ...(mod !== undefined && { mod }),
+ ...(shift !== undefined && { shift }),
+ ...(alt !== undefined && { alt }),
+ });
+/**
+ * Keys are host ids and contribution keys chosen elsewhere, so the record has
+ * no prototype: `overrides.constructor` is a stored binding or undefined, never
+ * Object.prototype's function.
+ */
+const freezeOverrides = (
+ entries: Iterable,
+): ShortcutOverrides => {
+ const overrides: Record = Object.create(null);
+ for (const [key, binding] of entries) overrides[key] = freezeBinding(binding);
+ return Object.freeze(overrides);
+};
+const NO_OVERRIDES = freezeOverrides([]);
+
/** Keeps every well-formed entry and drops only malformed ones. */
export function parseOverrides(raw: unknown): ShortcutOverrides {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
- const entries = Object.entries(raw as Record).filter(
- ([key, binding]) => key.length > 0 && isKeyBinding(binding),
- ) as [string, KeyBinding][];
- return Object.freeze(
- Object.fromEntries(
- entries.map(([key, { key: name, mod, shift, alt }]) => [
- key,
- Object.freeze({
- key: name,
- ...(mod !== undefined && { mod }),
- ...(shift !== undefined && { shift }),
- ...(alt !== undefined && { alt }),
- }),
- ]),
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
+ return NO_OVERRIDES;
+ return freezeOverrides(
+ Object.entries(raw as Record).filter(
+ (entry): entry is [string, KeyBinding] =>
+ entry[0].length > 0 && isKeyBinding(entry[1]),
),
);
}
@@ -37,23 +49,35 @@ export function parseOverrides(raw: unknown): ShortcutOverrides {
export function createShortcutBindings(
host: Window | undefined = typeof window === "undefined" ? undefined : window,
) {
- let state: ShortcutBindingsSnapshot = { overrides: {}, error: null };
+ let state: ShortcutBindingsSnapshot = {
+ overrides: NO_OVERRIDES,
+ error: null,
+ };
+ // What the dispatcher reads on every keydown: one frozen alias list per key,
+ // built once per change so matching never allocates and a Map lookup never
+ // reaches an inherited property.
+ let resolved: ReadonlyMap = new Map();
let disposed = false;
const listeners = new Set<() => void>();
- const notify = () => {
+ const publish = (next: ShortcutBindingsSnapshot) => {
+ state = next;
+ resolved = new Map(
+ Object.entries(next.overrides).map(
+ ([key, binding]) => [key, Object.freeze([binding])] as const,
+ ),
+ );
for (const listener of listeners) listener();
};
const restore = () => {
try {
const raw = host?.localStorage.getItem(SHORTCUT_BINDINGS_KEY);
- state = {
- overrides: raw ? parseOverrides(JSON.parse(raw)) : {},
+ publish({
+ overrides: raw ? parseOverrides(JSON.parse(raw)) : NO_OVERRIDES,
error: null,
- };
+ });
} catch {
- state = { overrides: {}, error: RESTORE_ERROR };
+ publish({ overrides: NO_OVERRIDES, error: RESTORE_ERROR });
}
- notify();
};
// The change applies in memory even when the save fails; the page offers a retry.
const commit = (overrides: ShortcutOverrides) => {
@@ -69,8 +93,7 @@ export function createShortcutBindings(
} catch {
error = SAVE_ERROR;
}
- state = { overrides, error };
- notify();
+ publish({ overrides, error });
};
const onStorage = (event: StorageEvent) => {
if (event.key !== SHORTCUT_BINDINGS_KEY && event.key !== null) return;
@@ -94,24 +117,20 @@ export function createShortcutBindings(
};
},
/** Dispatcher lookup at match time; undefined means the registered default. */
- resolve: (key: string) => state.overrides[key],
+ resolve: (key: string) => resolved.get(key),
set(key: string, binding: KeyBinding | null) {
if (disposed) return;
if (binding !== null && !isKeyBinding(binding))
throw new Error("Invalid shortcut binding");
- const rest = Object.fromEntries(
- Object.entries(state.overrides).filter(([entry]) => entry !== key),
+ const rest = Object.entries(state.overrides).filter(
+ ([entry]) => entry !== key,
);
commit(
- Object.freeze(
- binding === null
- ? rest
- : { ...rest, [key]: Object.freeze({ ...binding }) },
- ),
+ freezeOverrides(binding === null ? rest : [...rest, [key, binding]]),
);
},
reset() {
- if (!disposed) commit(Object.freeze({}));
+ if (!disposed) commit(NO_OVERRIDES);
},
/** Re-attempt persisting what is already active after a failed save. */
retry() {
diff --git a/src/features/shortcuts/service.test.ts b/src/features/shortcuts/service.test.ts
index 50e04c36d..9cc027019 100644
--- a/src/features/shortcuts/service.test.ts
+++ b/src/features/shortcuts/service.test.ts
@@ -1,6 +1,7 @@
import { Context } from "@deepseek-ai/cordis";
import { expect, it, vi } from "vitest";
import { ShortcutsService } from "./service";
+import { createShortcutBindings } from "./preferences";
import { PluginRuntime } from "../../plugins/runtime";
import type { PluginInfo } from "../../plugins/types";
import type { KeyBinding, Shortcut } from "./bindings";
@@ -295,7 +296,7 @@ it("resolves user overrides at match time for plugin and host bindings", async (
const b = browser(),
root = new Context(),
calls: string[] = [];
- const overrides = new Map();
+ const overrides = new Map();
const runtime = new PluginRuntime(root, async (info) => ({
inject: ["shortcuts"],
apply(ctx) {
@@ -315,7 +316,7 @@ it("resolves user overrides at match time for plugin and host bindings", async (
// No override: the registered default applies.
b.key();
expect(calls).toEqual(["a:one"]);
- overrides.set("a/action", { key: "p", mod: true, shift: true });
+ overrides.set("a/action", [{ key: "p", mod: true, shift: true }]);
expect(b.key().defaultPrevented).toBe(false);
expect(b.key("p", { shiftKey: true }).defaultPrevented).toBe(true);
expect(calls).toEqual(["a:one", "a:one"]);
@@ -336,7 +337,7 @@ it("resolves user overrides at match time for plugin and host bindings", async (
id: "host-action",
binding: { key: "h", mod: true },
});
- overrides.set("host-action", { key: "p", mod: true, shift: true });
+ overrides.set("host-action", [{ key: "p", mod: true, shift: true }]);
b.key("p", { shiftKey: true });
expect(hostRun).toHaveBeenCalledTimes(1);
expect(calls).toHaveLength(3);
@@ -353,6 +354,63 @@ it("resolves user overrides at match time for plugin and host bindings", async (
}
});
+it("keeps dispatching when a host id shadows an Object.prototype name or a resolver misbehaves", async () => {
+ const b = browser(),
+ root = new Context(),
+ run = vi.fn();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const store = createShortcutBindings(undefined);
+ const service = new ShortcutsService(root, b.host, store);
+ try {
+ // "constructor" passes the host id rule; the lookup must yield a binding
+ // list or nothing, never Object.prototype.constructor.
+ service.registerHost({ ...shortcut(run), id: "constructor" });
+ expect(store.resolve("constructor")).toBeUndefined();
+ expect(b.key().defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(1);
+ store.set("constructor", { key: "p", mod: true });
+ expect(store.resolve("constructor")).toEqual([{ key: "p", mod: true }]);
+ expect(b.key().defaultPrevented).toBe(false);
+ expect(b.key("p").defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(2);
+ } finally {
+ store.dispose();
+ await root.fiber.dispose();
+ }
+ // Anything that is not a nonempty binding list means the registered default.
+ for (const result of [
+ Object,
+ "⌘P",
+ { key: "p", mod: true },
+ [],
+ [{ key: 5 }],
+ [{ key: "p", mod: "yes" }],
+ null,
+ ]) {
+ const ctx = new Context(),
+ fallback = vi.fn();
+ ctx.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const broken = new ShortcutsService(ctx, b.host, {
+ resolve: () => result as never,
+ });
+ try {
+ broken.registerHost(shortcut(fallback));
+ expect(b.key("p").defaultPrevented).toBe(false);
+ expect(b.key().defaultPrevented).toBe(true);
+ expect(fallback).toHaveBeenCalledTimes(1);
+ } finally {
+ await ctx.fiber.dispose();
+ }
+ }
+ expect(b.listeners.size).toBe(0);
+});
+
it("exposes host bindings to the host with change notifications", async () => {
const b = browser(),
ctx = new Context();
diff --git a/src/features/shortcuts/service.ts b/src/features/shortcuts/service.ts
index 06466b4ce..bec2b8b20 100644
--- a/src/features/shortcuts/service.ts
+++ b/src/features/shortcuts/service.ts
@@ -6,9 +6,11 @@ import {
} from "../../plugins/contributions";
import {
inEditable,
+ isBindingList,
matches,
normalizeShortcut,
type KeyBinding,
+ type NormalizedShortcut,
type Shortcut,
} from "./bindings";
export type { KeyBinding, Shortcut } from "./bindings";
@@ -20,7 +22,8 @@ export type Shortcuts = {
};
/** Device-local user rebinds, keyed by host id or namespaced contribution key. */
export type BindingOverrides = {
- resolve(key: string): KeyBinding | undefined;
+ /** A stored override is a nonempty alias list; anything else means the default. */
+ resolve(key: string): readonly KeyBinding[] | undefined;
};
declare module "@deepseek-ai/cordis" {
interface Context {
@@ -30,9 +33,9 @@ declare module "@deepseek-ai/cordis" {
export class ShortcutsService extends Service implements Shortcuts {
private readonly contributions;
- private readonly hostBindings = new Map();
+ private readonly hostBindings = new Map();
private readonly hostListeners = new Set<() => void>();
- private hostReady: readonly Shortcut[] = Object.freeze([]);
+ private hostReady: readonly NormalizedShortcut[] = Object.freeze([]);
constructor(
ctx: Context,
host: Window | undefined = typeof window === "undefined"
@@ -41,12 +44,16 @@ export class ShortcutsService extends Service implements Shortcuts {
overrides?: BindingOverrides,
) {
super(ctx, "shortcuts");
- this.contributions = createContributions(ctx);
+ this.contributions = createContributions(ctx);
const apple = /Mac|iPhone|iPad/.test(host?.navigator.platform ?? "");
// The effective chord is resolved at match time, so a rebind never requires
- // re-registration and survives plugin disable/enable.
- const bindingsFor = (shortcut: Shortcut, key: string) =>
- overrides?.resolve(key) ?? shortcut.binding;
+ // re-registration and survives plugin disable/enable. Anything that is not
+ // a binding list (a malformed store entry, an inherited property under a
+ // key such as "constructor") means the default, so dispatch cannot throw.
+ const bindingsFor = (shortcut: NormalizedShortcut, key: string) => {
+ const override = overrides?.resolve(key);
+ return isBindingList(override) ? override : shortcut.binding;
+ };
const dispatch = (event: KeyboardEvent) => {
if (
event.defaultPrevented ||
From 7335a9f07e8803e858cd61fcd5f49963bea1d837 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 15:14:20 +1000
Subject: [PATCH 03/10] feat(shortcuts): order plugin settings bindings
Signed-off-by: Matt Toohey
---
docs/plugin-architecture.md | 10 +++++
src/app/ShortcutSettings.test.tsx | 62 +++++++++++++++++++++++++-
src/app/ShortcutSettings.tsx | 32 +++++++++++--
src/features/shortcuts/bindings.ts | 8 +++-
src/features/shortcuts/service.test.ts | 38 ++++++++++++++++
src/features/shortcuts/service.ts | 4 +-
6 files changed, 144 insertions(+), 10 deletions(-)
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 8839454f6..581e92cb5 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -480,6 +480,8 @@ export function apply(ctx: Context) {
id: "show-details",
title: "Show details",
binding: { key: "k", mod: true, shift: true },
+ // Optional Settings presentation order within this plugin's category.
+ order: 10,
when: () => detailsViewIsAvailable(),
run: () => showDetails(),
};
@@ -510,6 +512,14 @@ or a promise that every binding wins every current focus conflict. The host-only
registration method is deliberately absent from the injected type contract; plugins
remain trusted same-process code, not sandboxed adversaries.
+`order` is optional and defaults to `0`. It controls only the row order in Settings
+within this plugin's category; lower values appear first. Equal orders use the
+stable namespaced contribution key (`pluginId/shortcutId`), then title, as
+presentation tie-breakers. The host category keeps its explicit host title/key
+ordering policy. Presentation order does not affect dispatch precedence, and
+shortcuts with duplicate titles remain separate rows because registry keys—not
+titles—identify bindings and their overrides.
+
See [`shortcut-counter`](../examples/plugins/shortcut-counter/README.md) for a
self-contained external plugin using the real service without a DOM listener.
The generated type-only `@buzz/author` exports `Shortcuts`, `Shortcut`, `KeyBinding`
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 0171d8207..66e247d4a 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -111,8 +111,16 @@ async function harness() {
id: "increment",
title: "Increment shortcut counter",
binding: { key: "k", mod: true, shift: true },
+ order: 10,
run: runs.increment,
});
+ await contribute("example.counter", {
+ id: "first",
+ title: "First action",
+ binding: { key: "l", mod: true, shift: true },
+ order: -10,
+ run: vi.fn(),
+ });
await contribute("buzz.terminal", {
id: "toggle",
title: "Toggle channel terminal",
@@ -180,6 +188,7 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
"Increase text size",
"Open Settings",
"Search Buzz",
+ "First action",
"Increment shortcut counter",
"Toggle channel terminal",
]);
@@ -203,8 +212,9 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
const search = screen.getByRole("searchbox", { name: "Search shortcuts" });
await user.type(search, "counter");
- expect(screen.getAllByRole("article")).toHaveLength(1);
+ expect(screen.getAllByRole("article")).toHaveLength(2);
expect(row("Increment shortcut counter")).toBeInTheDocument();
+ expect(row("First action")).toBeInTheDocument();
await user.clear(search);
await user.type(search, "⌘J");
expect(screen.getAllByRole("article")).toHaveLength(1);
@@ -223,7 +233,7 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
expect(
screen.queryByRole("heading", { name: "Terminal" }),
).not.toBeInTheDocument();
- expect(screen.getAllByRole("article")).toHaveLength(4);
+ expect(screen.getAllByRole("article")).toHaveLength(5);
act(() => h.setActive("buzz.terminal", true));
expect(row("Toggle channel terminal")).toBeInTheDocument();
} finally {
@@ -231,6 +241,54 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
}
});
+it("orders plugin rows by metadata then contribution key without merging duplicate titles", async () => {
+ const h = await harness();
+ try {
+ await h.contribute("example.counter", {
+ id: "aaa",
+ title: "Increment shortcut counter",
+ binding: { key: "l", mod: true },
+ order: 10,
+ run: vi.fn(),
+ });
+ render(
+ ,
+ );
+ expect(
+ screen
+ .getAllByRole("article")
+ .map(
+ (article) =>
+ within(article).getByRole("heading", { level: 3 }).textContent,
+ ),
+ ).toEqual([
+ "Increase text size",
+ "Open Settings",
+ "Search Buzz",
+ "First action",
+ "Increment shortcut counter",
+ "Increment shortcut counter",
+ "Toggle channel terminal",
+ ]);
+ const duplicates = screen.getAllByRole("article", {
+ name: "Increment shortcut counter",
+ });
+ const [firstDuplicate, secondDuplicate] = duplicates;
+ if (!firstDuplicate || !secondDuplicate)
+ throw new Error("Missing duplicate row");
+ expect(within(firstDuplicate).getByText("Control L")).toBeInTheDocument();
+ expect(
+ within(secondDuplicate).getByText("Control Shift K"),
+ ).toBeInTheDocument();
+ } finally {
+ await h.dispose();
+ }
+});
+
it("captures a chord, refuses conflicts and bare keys, applies overrides to the dispatcher, and resets", async () => {
const user = userEvent.setup();
const h = await harness();
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
index 8deaa6142..fb4ca1ab8 100644
--- a/src/app/ShortcutSettings.tsx
+++ b/src/app/ShortcutSettings.tsx
@@ -28,11 +28,19 @@ type Row = Readonly<{
key: string;
title: string;
owner: string;
+ /** Plugin-defined presentation order; host rows use the host policy below. */
+ order: number;
defaults: readonly KeyBinding[];
override: KeyBinding | undefined;
effective: readonly KeyBinding[];
}>;
-type Group = Readonly<{ id: string; label: string; rows: readonly Row[] }>;
+type Group = Readonly<{
+ id: string;
+ label: string;
+ /** Stable owner identity for equal display labels. */
+ sortKey: string;
+ rows: readonly Row[];
+}>;
type Notice = Readonly<{
key: string;
tone: "error" | "warning";
@@ -64,8 +72,13 @@ const DESKTOP_CHORDS: readonly KeyBinding[] = [
];
const includes = (chords: readonly KeyBinding[], binding: KeyBinding) =>
chords.some((chord) => sameBinding(chord, binding));
-const byTitle = (a: Row, b: Row) =>
+/** Host ordering is intentionally title/key based; plugin order is part of their API. */
+const byHostPresentation = (a: Row, b: Row) =>
a.title.localeCompare(b.title) || a.key.localeCompare(b.key);
+const byPluginPresentation = (a: Row, b: Row) =>
+ a.order - b.order ||
+ a.key.localeCompare(b.key) ||
+ a.title.localeCompare(b.title);
/**
* Lists every host binding and every active plugin contribution from the live
@@ -123,6 +136,7 @@ export function ShortcutSettings({
key,
title: shortcut.title,
owner,
+ order: shortcut.order,
defaults: shortcut.binding,
override,
effective: override ? [override] : shortcut.binding,
@@ -134,6 +148,7 @@ export function ShortcutSettings({
{
id: "host",
label: "Buzz",
+ sortKey: "",
rows: host.map((shortcut) => row(shortcut.id, shortcut, "Buzz")),
},
...[...new Set(contributed.map((shortcut) => shortcut.pluginId))]
@@ -142,14 +157,23 @@ export function ShortcutSettings({
return {
id: `plugin:${pluginId}`,
label,
+ sortKey: pluginId,
rows: contributed
.filter((shortcut) => shortcut.pluginId === pluginId)
.map((shortcut) => row(shortcut.key, shortcut, label)),
};
})
- .sort((a, b) => a.label.localeCompare(b.label)),
+ .sort(
+ (a, b) =>
+ a.label.localeCompare(b.label) || a.sortKey.localeCompare(b.sortKey),
+ ),
]
- .map((group) => ({ ...group, rows: [...group.rows].sort(byTitle) }))
+ .map((group) => ({
+ ...group,
+ rows: [...group.rows].sort(
+ group.id === "host" ? byHostPresentation : byPluginPresentation,
+ ),
+ }))
.filter((group) => group.rows.length);
const rows = groups.flatMap((group) => group.rows);
// A chord can become shared after capture (a plugin enabled later, a new
diff --git a/src/features/shortcuts/bindings.ts b/src/features/shortcuts/bindings.ts
index 60f30ad96..5e17c0808 100644
--- a/src/features/shortcuts/bindings.ts
+++ b/src/features/shortcuts/bindings.ts
@@ -17,11 +17,13 @@ export type Shortcut = Readonly<{
allowInModal?: boolean;
/** Held keys are consumed but run only once unless opted in. */
repeat?: boolean;
+ /** Lower values appear first within this plugin's Settings category; defaults to zero. */
+ order?: number;
}>;
/** What the registries hold: aliases are always a frozen array, never a bare binding. */
export type NormalizedShortcut = Shortcut &
- Readonly<{ binding: readonly KeyBinding[] }>;
+ Readonly<{ binding: readonly KeyBinding[]; order: number }>;
export function normalizeShortcut(shortcut: Shortcut): NormalizedShortcut {
if (
@@ -34,7 +36,8 @@ export function normalizeShortcut(shortcut: Shortcut): NormalizedShortcut {
(shortcut.when !== undefined && typeof shortcut.when !== "function") ||
[shortcut.allowInEditable, shortcut.allowInModal, shortcut.repeat].some(
(value) => value !== undefined && typeof value !== "boolean",
- )
+ ) ||
+ (shortcut.order !== undefined && typeof shortcut.order !== "number")
)
throw new Error("A shortcut needs an id, title, binding and run function");
const bindings = Array.isArray(shortcut.binding)
@@ -44,6 +47,7 @@ export function normalizeShortcut(shortcut: Shortcut): NormalizedShortcut {
throw new Error("Invalid shortcut binding");
return Object.freeze({
...shortcut,
+ order: Number.isFinite(shortcut.order) ? (shortcut.order ?? 0) : 0,
binding: Object.freeze(
bindings.map((binding) => Object.freeze({ ...binding })),
),
diff --git a/src/features/shortcuts/service.test.ts b/src/features/shortcuts/service.test.ts
index 9cc027019..ab6ee5a8b 100644
--- a/src/features/shortcuts/service.test.ts
+++ b/src/features/shortcuts/service.test.ts
@@ -213,6 +213,44 @@ it("hides bindings before async apply finishes and after failed activation", asy
}
});
+it("normalizes presentation order without changing the registered shortcut contract", async () => {
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const service = new ShortcutsService(root, undefined);
+ try {
+ const owner = root.extend({
+ pluginOwner: { id: "ordered", revision: "one" },
+ });
+ await owner.plugin((ctx) => {
+ ctx.shortcuts.register({
+ ...shortcut(),
+ id: "default-order",
+ });
+ ctx.shortcuts.register({
+ ...shortcut(),
+ id: "custom-order",
+ order: -5,
+ });
+ ctx.shortcuts.register({
+ ...shortcut(),
+ id: "non-finite-order",
+ order: Number.POSITIVE_INFINITY,
+ });
+ });
+ expect(service.snapshot().map(({ id, order }) => ({ id, order }))).toEqual([
+ { id: "default-order", order: 0 },
+ { id: "custom-order", order: -5 },
+ { id: "non-finite-order", order: 0 },
+ ]);
+ await owner.fiber.dispose();
+ } finally {
+ await root.fiber.dispose();
+ }
+});
+
it("validates and copies bindings, keeps owner checks, and contains async handler failures", async () => {
const b = browser(),
root = new Context();
diff --git a/src/features/shortcuts/service.ts b/src/features/shortcuts/service.ts
index bec2b8b20..60dcb57e4 100644
--- a/src/features/shortcuts/service.ts
+++ b/src/features/shortcuts/service.ts
@@ -13,8 +13,8 @@ import {
type NormalizedShortcut,
type Shortcut,
} from "./bindings";
-export type { KeyBinding, Shortcut } from "./bindings";
-export type RegisteredShortcut = Contribution;
+export type { KeyBinding, NormalizedShortcut, Shortcut } from "./bindings";
+export type RegisteredShortcut = Contribution;
export type Shortcuts = {
register(shortcut: Shortcut): void;
snapshot(): readonly RegisteredShortcut[];
From d490552992ef474a5962c2b3f2e672ce4c9ee660 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 15:31:16 +1000
Subject: [PATCH 04/10] feat(shortcuts): order host and plugin bindings
Signed-off-by: Matt Toohey
---
docs/design-system.md | 6 ++++-
docs/plugin-architecture.md | 12 ++++++---
examples/plugins/shortcut-counter/plugin.js | 2 ++
src/app/ShortcutSettings.test.tsx | 15 ++++++++++--
src/app/ShortcutSettings.tsx | 12 +++------
src/app/shell/PageSearch.tsx | 2 ++
src/app/shortcuts.ts | 27 ++++++++++++++++++++-
src/bundled/terminal/index.tsx | 2 ++
src/features/shortcuts/bindings.ts | 2 +-
9 files changed, 63 insertions(+), 17 deletions(-)
diff --git a/docs/design-system.md b/docs/design-system.md
index 927e31160..de9c6dfdb 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -118,7 +118,11 @@ does not overlap. Independent plugins that hard-code sizes and third-party shado
widgets need their own adapter; this is not a forced CSS rewrite of arbitrary code.
Settings → Shortcuts lists every host and active plugin shortcut from the live
-dispatcher, grouped by owner, with search, per-row Change/Reset and Reset all. It
+dispatcher, grouped by owner, with each owner's deliberate numeric order, search,
+per-row Change/Reset and Reset all. Buzz's host rows use a functional sequence
+(navigation, text sizing, search/settings, then development-only actions); plugins
+choose the order of their own actions. Equal orders use stable registry identity
+and then title as tie-breakers. It
is built from existing components (`SearchField`, `Button`, `NavigationSection`,
the Plugins-list row pattern) and `formatBinding`, which renders chords as glyphs
in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 581e92cb5..8dc847df5 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -513,10 +513,14 @@ registration method is deliberately absent from the injected type contract; plug
remain trusted same-process code, not sandboxed adversaries.
`order` is optional and defaults to `0`. It controls only the row order in Settings
-within this plugin's category; lower values appear first. Equal orders use the
-stable namespaced contribution key (`pluginId/shortcutId`), then title, as
-presentation tie-breakers. The host category keeps its explicit host title/key
-ordering policy. Presentation order does not affect dispatch precedence, and
+within this owner's category; lower values appear first. Every bundled plugin
+assigns deliberate values to its actions (for example, a primary action starts
+at `10`), leaving gaps for related actions to be added later. Equal orders use
+the stable namespaced contribution key (`pluginId/shortcutId`), then title, as
+presentation tie-breakers. The core Buzz host category uses the same metadata
+and a host-owned functional sequence: navigation, text sizing, search/settings,
+then development-only actions. Host rows use their bare IDs for tie-breaking.
+Presentation order does not affect dispatch precedence, and
shortcuts with duplicate titles remain separate rows because registry keys—not
titles—identify bindings and their overrides.
diff --git a/examples/plugins/shortcut-counter/plugin.js b/examples/plugins/shortcut-counter/plugin.js
index 39aef89ff..a2191ce06 100644
--- a/examples/plugins/shortcut-counter/plugin.js
+++ b/examples/plugins/shortcut-counter/plugin.js
@@ -12,6 +12,8 @@ export function apply(ctx) {
id: "increment",
title: "Increment shortcut counter",
binding: { key: "k", mod: true, shift: true },
+ // Settings order within the Shortcut counter category.
+ order: 10,
run: increment,
});
ctx.pages.register({
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 66e247d4a..8daec30af 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -81,12 +81,14 @@ async function harness() {
id: "settings",
title: "Open Settings",
binding: { key: ",", mod: true },
+ order: 80,
run: runs.settings,
});
shortcuts.registerHost({
id: "global-search",
title: "Search Buzz",
binding: { key: "k", mod: true },
+ order: 70,
run: runs.search,
});
shortcuts.registerHost({
@@ -98,6 +100,7 @@ async function harness() {
{ key: "=", mod: true, shift: true },
{ key: "+", mod: true, shift: true },
],
+ order: 41,
run: runs.grow,
});
const contribute = (id: string, shortcut: Shortcut) =>
@@ -186,8 +189,8 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
),
).toEqual([
"Increase text size",
- "Open Settings",
"Search Buzz",
+ "Open Settings",
"First action",
"Increment shortcut counter",
"Toggle channel terminal",
@@ -251,6 +254,13 @@ it("orders plugin rows by metadata then contribution key without merging duplica
order: 10,
run: vi.fn(),
});
+ await h.contribute("example.counter", {
+ id: "zzz",
+ title: "Zed action",
+ binding: { key: "m", mod: true },
+ order: 10,
+ run: vi.fn(),
+ });
render(
chords.some((chord) => sameBinding(chord, binding));
-/** Host ordering is intentionally title/key based; plugin order is part of their API. */
-const byHostPresentation = (a: Row, b: Row) =>
- a.title.localeCompare(b.title) || a.key.localeCompare(b.key);
-const byPluginPresentation = (a: Row, b: Row) =>
+/** Every owner supplies order; identity and title make ties deterministic. */
+const byPresentation = (a: Row, b: Row) =>
a.order - b.order ||
a.key.localeCompare(b.key) ||
a.title.localeCompare(b.title);
@@ -170,9 +168,7 @@ export function ShortcutSettings({
]
.map((group) => ({
...group,
- rows: [...group.rows].sort(
- group.id === "host" ? byHostPresentation : byPluginPresentation,
- ),
+ rows: [...group.rows].sort(byPresentation),
}))
.filter((group) => group.rows.length);
const rows = groups.flatMap((group) => group.rows);
diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx
index 56837243e..bcfa4ced3 100644
--- a/src/app/shell/PageSearch.tsx
+++ b/src/app/shell/PageSearch.tsx
@@ -69,6 +69,8 @@ export function PageSearch({
id: SEARCH_ID,
title: "Search Buzz",
binding: SEARCH_BINDING,
+ // Host Settings order: navigation, text sizing, search, settings.
+ order: 30,
allowInEditable: true,
run: begin,
}),
diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts
index ebe6ecff2..57f35a029 100644
--- a/src/app/shortcuts.ts
+++ b/src/app/shortcuts.ts
@@ -2,6 +2,21 @@ import type { ShortcutsService } from "../features/shortcuts/service";
import type { Appearance } from "../shared/theme/service";
/** Host actions use the same binding/dispatch rules as plugins, without fake plugin ownership. */
+// Settings presents the host category in functional sections: navigation,
+// text sizing, search/settings, then development-only actions.
+const HOST_SHORTCUT_ORDER = {
+ navigationBack: 10,
+ navigationBackArrow: 11,
+ navigationForward: 20,
+ navigationForwardArrow: 21,
+ textSizeIncrease: 40,
+ textSizeDecrease: 50,
+ textSizeReset: 60,
+ search: 70,
+ settings: 80,
+ development: 90,
+} as const;
+
export function registerAppShortcuts(
shortcuts: ShortcutsService,
appearance: Appearance,
@@ -13,6 +28,7 @@ export function registerAppShortcuts(
id: "settings",
title: "Open Settings",
binding: { key: ",", mod: true },
+ order: HOST_SHORTCUT_ORDER.settings,
allowInEditable: true,
when: () => ready,
run: openSettings,
@@ -22,6 +38,7 @@ export function registerAppShortcuts(
[
"font-increase",
"Increase text size",
+ HOST_SHORTCUT_ORDER.textSizeIncrease,
[
{ key: "=", mod: true },
{ key: "=", mod: true, shift: true },
@@ -33,20 +50,23 @@ export function registerAppShortcuts(
[
"font-decrease",
"Decrease text size",
+ HOST_SHORTCUT_ORDER.textSizeDecrease,
{ key: "-", mod: true },
() => appearance.setFontScale(appearance.snapshot().fontScale - 0.1),
],
[
"font-reset",
"Reset text size",
+ HOST_SHORTCUT_ORDER.textSizeReset,
{ key: "0", mod: true },
() => appearance.setFontScale(1),
],
] as const
- ).map(([id, title, binding, run]) =>
+ ).map(([id, title, order, binding, run]) =>
shortcuts.registerHost({
id,
title,
+ order,
binding,
run,
allowInEditable: true,
@@ -61,6 +81,7 @@ export function registerAppShortcuts(
id: "development-reload",
title: "Reload development app",
binding: { key: "r", mod: true },
+ order: HOST_SHORTCUT_ORDER.development,
allowInEditable: true,
allowInModal: true,
run: () => window.location.reload(),
@@ -80,6 +101,7 @@ export function registerNavigationShortcuts(
shortcuts.registerHost({
id: "navigation-back",
title: "Go back",
+ order: HOST_SHORTCUT_ORDER.navigationBack,
binding: { key: "[", mod: true },
allowInEditable: true,
when: () => navigation.snapshot().canGoBack,
@@ -88,6 +110,7 @@ export function registerNavigationShortcuts(
shortcuts.registerHost({
id: "navigation-forward",
title: "Go forward",
+ order: HOST_SHORTCUT_ORDER.navigationForward,
binding: { key: "]", mod: true },
allowInEditable: true,
when: () => navigation.snapshot().canGoForward,
@@ -98,6 +121,7 @@ export function registerNavigationShortcuts(
shortcuts.registerHost({
id: "navigation-back-arrow",
title: "Go back",
+ order: HOST_SHORTCUT_ORDER.navigationBackArrow,
binding: { key: "ArrowLeft", alt: true },
when: () => navigation.snapshot().canGoBack,
run: navigation.back,
@@ -105,6 +129,7 @@ export function registerNavigationShortcuts(
shortcuts.registerHost({
id: "navigation-forward-arrow",
title: "Go forward",
+ order: HOST_SHORTCUT_ORDER.navigationForwardArrow,
binding: { key: "ArrowRight", alt: true },
when: () => navigation.snapshot().canGoForward,
run: navigation.forward,
diff --git a/src/bundled/terminal/index.tsx b/src/bundled/terminal/index.tsx
index f5bd0a4b1..3571eb53b 100644
--- a/src/bundled/terminal/index.tsx
+++ b/src/bundled/terminal/index.tsx
@@ -30,6 +30,8 @@ export const apply: PluginModule["apply"] = (ctx) => {
id: "toggle",
title: "Toggle channel terminal",
binding: TOGGLE_BINDING,
+ // Plugin Settings order: the terminal action is the category's primary action.
+ order: 10,
allowInEditable: true,
when: () => !!binding?.available(),
run: () => binding?.toggle(),
diff --git a/src/features/shortcuts/bindings.ts b/src/features/shortcuts/bindings.ts
index 5e17c0808..12d6dd3e3 100644
--- a/src/features/shortcuts/bindings.ts
+++ b/src/features/shortcuts/bindings.ts
@@ -17,7 +17,7 @@ export type Shortcut = Readonly<{
allowInModal?: boolean;
/** Held keys are consumed but run only once unless opted in. */
repeat?: boolean;
- /** Lower values appear first within this plugin's Settings category; defaults to zero. */
+ /** Lower values appear first within this owner's Settings category; defaults to zero. */
order?: number;
}>;
From d21009355e1378347e3a3c936d54f26a55f14356 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 15:46:07 +1000
Subject: [PATCH 05/10] fix(shortcuts): remove duplicate option navigation
bindings
Signed-off-by: Matt Toohey
---
src/app/shortcuts.ts | 20 --------------------
tests/browser/navigation-boundaries.spec.mjs | 2 +-
tests/browser/navigation.spec.mjs | 4 +---
tests/browser/shortcuts.spec.mjs | 6 ++++++
4 files changed, 8 insertions(+), 24 deletions(-)
diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts
index 57f35a029..684815543 100644
--- a/src/app/shortcuts.ts
+++ b/src/app/shortcuts.ts
@@ -6,9 +6,7 @@ import type { Appearance } from "../shared/theme/service";
// text sizing, search/settings, then development-only actions.
const HOST_SHORTCUT_ORDER = {
navigationBack: 10,
- navigationBackArrow: 11,
navigationForward: 20,
- navigationForwardArrow: 21,
textSizeIncrease: 40,
textSizeDecrease: 50,
textSizeReset: 60,
@@ -116,24 +114,6 @@ export function registerNavigationShortcuts(
when: () => navigation.snapshot().canGoForward,
run: navigation.forward,
}),
- // Alt arrows are native word-editing chords on macOS. Keep them outside
- // editable fields; Mod+[ / Mod+] remain explicit history shortcuts.
- shortcuts.registerHost({
- id: "navigation-back-arrow",
- title: "Go back",
- order: HOST_SHORTCUT_ORDER.navigationBackArrow,
- binding: { key: "ArrowLeft", alt: true },
- when: () => navigation.snapshot().canGoBack,
- run: navigation.back,
- }),
- shortcuts.registerHost({
- id: "navigation-forward-arrow",
- title: "Go forward",
- order: HOST_SHORTCUT_ORDER.navigationForwardArrow,
- binding: { key: "ArrowRight", alt: true },
- when: () => navigation.snapshot().canGoForward,
- run: navigation.forward,
- }),
];
return () => {
for (const dispose of remove) dispose();
diff --git a/tests/browser/navigation-boundaries.spec.mjs b/tests/browser/navigation-boundaries.spec.mjs
index 97d74c1db..b508edfae 100644
--- a/tests/browser/navigation-boundaries.spec.mjs
+++ b/tests/browser/navigation-boundaries.spec.mjs
@@ -87,7 +87,7 @@ test("Messages default resolution returns opened to cold and warm callers withou
}
});
-test("Alt arrows preserve composer editing while deliberate history shortcuts still navigate", async ({
+test("native Alt arrows preserve composer editing; deliberate history shortcuts navigate", async ({
page,
app,
}) => {
diff --git a/tests/browser/navigation.spec.mjs b/tests/browser/navigation.spec.mjs
index 75f03bf1e..1e66305b6 100644
--- a/tests/browser/navigation.spec.mjs
+++ b/tests/browser/navigation.spec.mjs
@@ -46,10 +46,8 @@ test("channel visits, toolbar, browser traversal and Settings sections share one
await expect(
sections.getByRole("button", { name: "Appearance", exact: true }),
).toHaveAttribute("aria-current", "page");
+ // Option/Alt arrows are native editing controls, not Buzz history shortcuts.
await page.keyboard.press("Alt+ArrowLeft");
- await expect(
- sections.getByRole("button", { name: "Profile", exact: true }),
- ).toHaveAttribute("aria-current", "page");
await page.keyboard.press("Alt+ArrowRight");
await expect(
sections.getByRole("button", { name: "Appearance", exact: true }),
diff --git a/tests/browser/shortcuts.spec.mjs b/tests/browser/shortcuts.spec.mjs
index 20063405a..50ed3b017 100644
--- a/tests/browser/shortcuts.spec.mjs
+++ b/tests/browser/shortcuts.spec.mjs
@@ -264,6 +264,12 @@ test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflic
await expect(
region.getByRole("heading", { name: "Shortcut counter", exact: true }),
).toBeVisible();
+ await expect(
+ region.getByRole("article", { name: "Go back", exact: true }),
+ ).toHaveCount(1);
+ await expect(
+ region.getByRole("article", { name: "Go forward", exact: true }),
+ ).toHaveCount(1);
const row = region.getByRole("article", { name: title });
await expect(row.getByText(/Shift (Command|Control) K/)).toBeAttached();
await button(row, `Change shortcut for ${title}`).click();
From f19ec7ded1c6fec378dcc3aa74cabe5b2d3a8d4b Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 15:49:28 +1000
Subject: [PATCH 06/10] test(app): allow bundled pages integration headroom
Signed-off-by: Matt Toohey
---
src/app/pages.integration.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs
index 7c6470c4f..4857b2485 100644
--- a/src/app/pages.integration.test.mjs
+++ b/src/app/pages.integration.test.mjs
@@ -249,4 +249,4 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
Object.defineProperty(globalThis, "localStorage", originalStorage);
else delete globalThis.localStorage;
}
-});
+}, 15_000);
From 5e1173d251f2236f6c58a54ca99d9b7cb8982ec9 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 16:01:20 +1000
Subject: [PATCH 07/10] fix(shortcuts): assert platform-specific shortcut
labels
Use the existing platform modifier to assert exact spoken labels for the default K and rebound U shortcuts. Preserve dispatch, conflict, persistence and reset coverage without changing production formatting or browser timeouts.
Revert the unrelated 15-second bundled-pages timeout introduced by f19ec7ded1c6fec378dcc3aa74cabe5b2d3a8d4b; it does not address the deterministic Linux label mismatch.
Signed-off-by: Matt Toohey
---
src/app/pages.integration.test.mjs | 2 +-
tests/browser/shortcuts.spec.mjs | 10 ++++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs
index 4857b2485..7c6470c4f 100644
--- a/src/app/pages.integration.test.mjs
+++ b/src/app/pages.integration.test.mjs
@@ -249,4 +249,4 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
Object.defineProperty(globalThis, "localStorage", originalStorage);
else delete globalThis.localStorage;
}
-}, 15_000);
+});
diff --git a/tests/browser/shortcuts.spec.mjs b/tests/browser/shortcuts.spec.mjs
index 50ed3b017..d3046c748 100644
--- a/tests/browser/shortcuts.spec.mjs
+++ b/tests/browser/shortcuts.spec.mjs
@@ -252,6 +252,8 @@ test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflic
}) => {
await page.goto(app.origin);
const modifier = await mod(page);
+ const spokenModifiers =
+ modifier === "Meta" ? "Shift Command" : "Control Shift";
const title = "Increment shortcut counter";
await button(page, "Shortcut counter").first().click();
const count = page.getByRole("status");
@@ -271,7 +273,9 @@ test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflic
region.getByRole("article", { name: "Go forward", exact: true }),
).toHaveCount(1);
const row = region.getByRole("article", { name: title });
- await expect(row.getByText(/Shift (Command|Control) K/)).toBeAttached();
+ await expect(
+ row.getByText(`${spokenModifiers} K`, { exact: true }),
+ ).toBeAttached();
await button(row, `Change shortcut for ${title}`).click();
const listening = page.getByRole("textbox", {
name: `New shortcut for ${title}`,
@@ -287,7 +291,9 @@ test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflic
await page.keyboard.press(`${modifier}+Shift+u`);
await expect(listening).toHaveCount(0);
await expect(row.getByText("Modified")).toBeVisible();
- await expect(row.getByText(/Shift (Command|Control) U/)).toBeAttached();
+ await expect(
+ row.getByText(`${spokenModifiers} U`, { exact: true }),
+ ).toBeAttached();
await expect(button(row, `Reset shortcut for ${title}`)).toBeVisible();
await expect(button(row, `Change shortcut for ${title}`)).toBeFocused();
await button(page, "Shortcut counter").first().click();
From be8a7dfb334e21f410ca1a67769096098b07317c Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 16:52:03 +1000
Subject: [PATCH 08/10] fix(shortcuts): remove settings search and introductory
text
Remove the shortcut search field, its filtering state, and the introductory instructions while preserving grouped shortcut editing and reset controls. Update component coverage to assert the simplified settings view.
Signed-off-by: Matt Toohey
---
src/app/ShortcutSettings.test.tsx | 27 ++++++----------------
src/app/ShortcutSettings.tsx | 38 +++----------------------------
2 files changed, 10 insertions(+), 55 deletions(-)
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 8daec30af..272dd92cb 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -159,8 +159,7 @@ const change = (title: string) =>
const capture = (title: string) =>
screen.getByRole("textbox", { name: `New shortcut for ${title}` });
-it("lists live host and plugin shortcuts grouped by owner, searchable, and follows plugin state", async () => {
- const user = userEvent.setup();
+it("lists live host and plugin shortcuts without search or intro text and follows plugin state", async () => {
const h = await harness();
try {
render(
@@ -213,24 +212,12 @@ it("lists live host and plugin shortcuts grouped by owner, searchable, and follo
screen.getByRole("button", { name: "Reset all shortcuts" }),
).toBeDisabled();
- const search = screen.getByRole("searchbox", { name: "Search shortcuts" });
- await user.type(search, "counter");
- expect(screen.getAllByRole("article")).toHaveLength(2);
- expect(row("Increment shortcut counter")).toBeInTheDocument();
- expect(row("First action")).toBeInTheDocument();
- await user.clear(search);
- await user.type(search, "⌘J");
- expect(screen.getAllByRole("article")).toHaveLength(1);
- expect(row("Toggle channel terminal")).toBeInTheDocument();
- await user.clear(search);
- await user.type(search, "command ,");
- expect(screen.getAllByRole("article")).toHaveLength(1);
- expect(row("Open Settings")).toBeInTheDocument();
- await user.clear(search);
- await user.type(search, "zzz");
- expect(screen.queryAllByRole("article")).toHaveLength(0);
- expect(screen.getByText("No matching shortcuts.")).toBeInTheDocument();
- await user.clear(search);
+ expect(screen.queryByRole("searchbox")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ "Every shortcut from Buzz and your enabled plugins. Choose Change, then press the new keys; Escape cancels. Saved on this device.",
+ ),
+ ).not.toBeInTheDocument();
act(() => h.setActive("buzz.terminal", false));
expect(
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
index 2a79dbaee..f0574df36 100644
--- a/src/app/ShortcutSettings.tsx
+++ b/src/app/ShortcutSettings.tsx
@@ -8,7 +8,6 @@ import {
import { isTauri } from "@tauri-apps/api/core";
import { Button } from "../shared/design-system/ui/Button";
import { NavigationSection } from "../shared/design-system/ui/NavigationSection";
-import { SearchField } from "../shared/design-system/ui/SearchField";
import {
sameBinding,
type KeyBinding,
@@ -114,7 +113,6 @@ export function ShortcutSettings({
plugins.subscribe,
plugins.snapshot,
);
- const [query, setQuery] = useState("");
const [editing, setEditing] = useState(null);
const [notice, setNotice] = useState(null);
@@ -183,24 +181,6 @@ export function ShortcutSettings({
target.effective.some((current) => sameBinding(current, binding)),
),
);
- const needle = query.trim().toLowerCase();
- const visible = needle
- ? groups
- .map((group) => ({
- ...group,
- rows: group.rows.filter((row) =>
- [
- row.title,
- row.owner,
- ...row.effective.flatMap((binding) => {
- const { text, label } = formatBinding(binding, apple);
- return [text, label];
- }),
- ].some((text) => text.toLowerCase().includes(needle)),
- ),
- }))
- .filter((group) => group.rows.length)
- : groups;
const modified = Object.keys(overrides).length > 0;
const start = (key: string) => {
@@ -270,19 +250,9 @@ export function ShortcutSettings({
Shortcuts
-
- Every shortcut from Buzz and your enabled plugins. Choose Change, then
- press the new keys; Escape cancels. Saved on this device.
-
-
- {visible.length ? (
+ {groups.length ? (
- {visible.map((group) => (
+ {groups.map((group) => (
{group.rows.map((row) => (
@@ -309,9 +279,7 @@ export function ShortcutSettings({
) : (
- {rows.length
- ? "No matching shortcuts."
- : "No shortcuts are available yet."}
+ No shortcuts are available yet.
)}
From f0f1eab6086c01b7b047da948f11871aee51fbc5 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 17:11:07 +1000
Subject: [PATCH 09/10] fix(shortcuts): align capture warnings and host
ordering
Address the three review findings on shortcut settings: warn when accepted
modified Enter chords are handled by the message editor, reuse
HOST_SHORTCUT_ORDER.search in PageSearch, and ignore AltGraph during capture
without changing the dispatcher's safety guard or simplified settings UI.
Add regressions for both platform mappings, actual host registration order,
and capture through persistence to dispatch, including AltGraph rejection
and continued support for ordinary Control+Alt chords.
Signed-off-by: Matt Toohey
---
src/app/ShortcutSettings.test.tsx | 186 +++++++++++++++++++
src/app/ShortcutSettings.tsx | 3 +-
src/app/shell/PageSearch.tsx | 4 +-
src/app/shortcuts.ts | 2 +-
src/features/shortcuts/KeyCaptureControl.tsx | 2 +
5 files changed, 193 insertions(+), 4 deletions(-)
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 272dd92cb..25915d220 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -20,6 +20,11 @@ import {
import { ShortcutsService } from "../features/shortcuts/service";
import type { PluginManager } from "../plugins/manager";
import type { PluginInfo } from "../plugins/types";
+import { createAppearance } from "../shared/theme/service";
+import { createNavigationController } from "../features/navigation/controller";
+import { createMemoryHistory } from "../features/navigation/history";
+import { registerAppShortcuts, registerNavigationShortcuts } from "./shortcuts";
+import { PageSearch, type SearchServices } from "./shell/PageSearch";
afterEach(() => {
cleanup();
@@ -231,6 +236,70 @@ it("lists live host and plugin shortcuts without search or intro text and follow
}
});
+it("presents actual host registrations in navigation, text sizing, search/settings, then development order", async () => {
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const bindings = createShortcutBindings(window);
+ const shortcuts = new ShortcutsService(root, window, bindings);
+ const appearance = createAppearance(window);
+ const navigation = createNavigationController(createMemoryHistory());
+ const removeApp = registerAppShortcuts(shortcuts, appearance, vi.fn(), true);
+ const removeNavigation = registerNavigationShortcuts(
+ shortcuts,
+ navigation.navigation,
+ );
+ try {
+ render(
+ <>
+
+
+ >,
+ );
+ expect(
+ screen
+ .getAllByRole("article")
+ .map(
+ (article) =>
+ within(article).getByRole("heading", { level: 3 }).textContent,
+ ),
+ ).toEqual([
+ "Go back",
+ "Go forward",
+ "Increase text size",
+ "Decrease text size",
+ "Reset text size",
+ "Search Buzz",
+ "Open Settings",
+ ...(import.meta.env.DEV ? ["Reload development app"] : []),
+ ]);
+ } finally {
+ cleanup();
+ removeNavigation();
+ removeApp();
+ navigation.dispose();
+ appearance.dispose();
+ bindings.dispose();
+ await root.fiber.dispose();
+ }
+});
+
it("orders plugin rows by metadata then contribution key without merging duplicate titles", async () => {
const h = await harness();
try {
@@ -484,6 +553,123 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
}
});
+it.each([true, false])(
+ "warns for every accepted Enter modifier combination but still dispatches outside the editor (Apple=%s)",
+ async (apple) => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue(
+ apple ? "MacIntel" : "Win32",
+ );
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ render(
+ ,
+ );
+ const title = "Open Settings";
+ await user.click(change(title));
+ for (const shiftKey of [false, true]) {
+ fireEvent.keyDown(capture(title), { key: "Enter", shiftKey });
+ expect(screen.getByRole("alert")).toHaveTextContent("Include");
+ expect(h.bindings.resolve("settings")).toBeUndefined();
+ }
+ let dispatched = 0;
+ for (const modifiers of [
+ { mod: true },
+ { mod: true, shift: true },
+ { alt: true },
+ { alt: true, shift: true },
+ { mod: true, alt: true },
+ { mod: true, alt: true, shift: true },
+ ]) {
+ if (dispatched) await user.click(change(title));
+ const event = {
+ key: "Enter",
+ ctrlKey: !apple && !!modifiers.mod,
+ metaKey: apple && !!modifiers.mod,
+ altKey: !!modifiers.alt,
+ shiftKey: !!modifiers.shift,
+ };
+ fireEvent.keyDown(capture(title), event);
+ expect(within(row(title)).getByRole("alert")).toHaveTextContent(
+ "Saved. The message editor handles",
+ );
+ expect(within(row(title)).getByRole("alert")).toHaveClass(
+ "text-warning",
+ );
+ expect(h.bindings.resolve("settings")).toEqual([
+ { key: "Enter", ...modifiers },
+ ]);
+ expect(change(title)).toHaveFocus();
+ expect(h.runs.settings).toHaveBeenCalledTimes(dispatched);
+ expect(fireEvent.keyDown(document.body, event)).toBe(false);
+ expect(h.runs.settings).toHaveBeenCalledTimes(++dispatched);
+ }
+ } finally {
+ await h.dispose();
+ }
+ },
+);
+
+it("ignores AltGraph capture without changing a saved override, then captures and dispatches a non-AltGraph chord", async () => {
+ const user = userEvent.setup();
+ const h = await harness();
+ try {
+ h.bindings.set("settings", { key: "u", mod: true });
+ const saved = localStorage.getItem(SHORTCUT_BINDINGS_KEY);
+ render(
+ ,
+ );
+ const title = "Open Settings";
+ await user.click(change(title));
+ const input = capture(title);
+ // Windows German AltGr+Q reports @ with Control and Alt held.
+ const chord = { key: "@", ctrlKey: true, altKey: true };
+ const altGraph = () =>
+ new KeyboardEvent("keydown", {
+ ...chord,
+ modifierAltGraph: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ const event = altGraph();
+ expect(event.getModifierState("AltGraph")).toBe(true);
+ fireEvent(input, event);
+ expect(capture(title)).toBe(input);
+ expect(input).toHaveFocus();
+ expect(h.bindings.resolve("settings")).toEqual([{ key: "u", mod: true }]);
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(saved);
+ expect(h.runs.settings).not.toHaveBeenCalled();
+
+ // Control+Alt is still usable when the event is not AltGraph.
+ fireEvent.keyDown(input, chord);
+ expect(change(title)).toHaveFocus();
+ expect(h.bindings.resolve("settings")).toEqual([
+ { key: "@", mod: true, alt: true },
+ ]);
+ expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(
+ JSON.stringify({ settings: { key: "@", mod: true, alt: true } }),
+ );
+ // The original dispatcher safety guard must still ignore AltGraph even
+ // when its key and modifier flags match a valid saved override exactly.
+ expect(fireEvent(document.body, altGraph())).toBe(true);
+ expect(h.press("u")).toBe(true);
+ expect(h.runs.settings).not.toHaveBeenCalled();
+ expect(fireEvent.keyDown(document.body, chord)).toBe(false);
+ expect(h.runs.settings).toHaveBeenCalledTimes(1);
+ } finally {
+ await h.dispose();
+ }
+});
+
it("keeps a change active when saving fails and offers a retry", async () => {
const user = userEvent.setup();
const h = await harness();
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
index f0574df36..1677f763a 100644
--- a/src/app/ShortcutSettings.tsx
+++ b/src/app/ShortcutSettings.tsx
@@ -233,8 +233,9 @@ export function ShortcutSettings({
target.defaults.every((current) => sameBinding(current, binding));
bindings.set(target.key, isDefault ? null : binding);
setEditing(null);
+ // EditableInput handles Enter locally regardless of the held modifiers.
setNotice(
- includes(EDITOR_CHORDS, binding)
+ binding.key === "Enter" || includes(EDITOR_CHORDS, binding)
? {
key: target.key,
tone: "warning",
diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx
index bcfa4ced3..cb2764ddf 100644
--- a/src/app/shell/PageSearch.tsx
+++ b/src/app/shell/PageSearch.tsx
@@ -19,6 +19,7 @@ import {
} from "../../features/shortcuts/format";
import type { ShortcutBindingsSnapshot } from "../../features/shortcuts/preferences";
import type { AppServices } from "../services";
+import { HOST_SHORTCUT_ORDER } from "../shortcuts";
import {
orderPages,
pagePresentation,
@@ -69,8 +70,7 @@ export function PageSearch({
id: SEARCH_ID,
title: "Search Buzz",
binding: SEARCH_BINDING,
- // Host Settings order: navigation, text sizing, search, settings.
- order: 30,
+ order: HOST_SHORTCUT_ORDER.search,
allowInEditable: true,
run: begin,
}),
diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts
index 684815543..0d8747307 100644
--- a/src/app/shortcuts.ts
+++ b/src/app/shortcuts.ts
@@ -4,7 +4,7 @@ import type { Appearance } from "../shared/theme/service";
/** Host actions use the same binding/dispatch rules as plugins, without fake plugin ownership. */
// Settings presents the host category in functional sections: navigation,
// text sizing, search/settings, then development-only actions.
-const HOST_SHORTCUT_ORDER = {
+export const HOST_SHORTCUT_ORDER = {
navigationBack: 10,
navigationForward: 20,
textSizeIncrease: 40,
diff --git a/src/features/shortcuts/KeyCaptureControl.tsx b/src/features/shortcuts/KeyCaptureControl.tsx
index 8f6227da2..309a0a17f 100644
--- a/src/features/shortcuts/KeyCaptureControl.tsx
+++ b/src/features/shortcuts/KeyCaptureControl.tsx
@@ -73,6 +73,8 @@ export function KeyCaptureControl({
onCancel();
return;
}
+ // AltGr can report Control+Alt, but the dispatcher always ignores it.
+ if (event.getModifierState("AltGraph")) return;
const mod = apple ? event.metaKey : event.ctrlKey;
onCapture({
binding: {
From 690b5a9e2ba9aa84a997dfea4187ce8fdce1b445 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 23 Sep 2026 17:38:23 +1000
Subject: [PATCH 10/10] fix(shortcuts): correct capture accessibility and
terminal rebinding
Reuse the shared Input and neutral notices, wrap capture actions before titles collapse, and let Tab leave capture without invoking plugin bindings. Keep Escape cancellation discoverable and formatting safe for restored prototype-named keys.
Replace xterm's hard-coded default exemption with a private synchronous handoff of the original keydown to the existing dispatcher. Preserve live overrides, eligibility and lifetime ownership without changing the plugin API or ordinary editor precedence.
Add restore-to-shell-render regressions, dispatch and keyboard-exit coverage, and two browser cases for focus/layout and real focused-xterm rebinding. Verified 61 focused tests, 79 design tests, 36 Chromium/WebKit checks, TypeScript and design checks/build. Native/package acceptance and hosted CI remain deferred.
Signed-off-by: Matt Toohey
---
docs/design-system.md | 11 +-
docs/plugin-architecture.md | 7 +-
docs/terminal.md | 15 +-
src/app/ShortcutSettings.test.tsx | 48 +++++-
src/app/ShortcutSettings.tsx | 10 +-
src/app/shell/PageSearch.test.tsx | 40 ++++-
src/bundled/terminal/renderer.ts | 21 ++-
.../shortcuts/KeyCaptureControl.module.css | 14 +-
src/features/shortcuts/KeyCaptureControl.tsx | 92 +++++++-----
src/features/shortcuts/format.ts | 5 +-
src/features/shortcuts/preferences.test.ts | 20 +++
src/features/shortcuts/service.test.ts | 11 +-
src/features/shortcuts/service.ts | 6 +
.../shortcuts/terminal-key-event.test.ts | 103 +++++++++++++
src/features/shortcuts/terminal-key-event.ts | 17 +++
tests/browser/shortcuts.spec.mjs | 129 ++++++++++++++++
tests/browser/terminal-renderer.spec.mjs | 139 ++++++++++++++++--
tests/fixtures/terminal-panel.tsx | 72 ++++++---
18 files changed, 645 insertions(+), 115 deletions(-)
create mode 100644 src/features/shortcuts/terminal-key-event.test.ts
create mode 100644 src/features/shortcuts/terminal-key-event.ts
diff --git a/docs/design-system.md b/docs/design-system.md
index de9c6dfdb..85773128d 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -118,12 +118,12 @@ does not overlap. Independent plugins that hard-code sizes and third-party shado
widgets need their own adapter; this is not a forced CSS rewrite of arbitrary code.
Settings → Shortcuts lists every host and active plugin shortcut from the live
-dispatcher, grouped by owner, with each owner's deliberate numeric order, search,
+dispatcher, grouped by owner, with each owner's deliberate numeric order,
per-row Change/Reset and Reset all. Buzz's host rows use a functional sequence
(navigation, text sizing, search/settings, then development-only actions); plugins
choose the order of their own actions. Equal orders use stable registry identity
and then title as tie-breakers. It
-is built from existing components (`SearchField`, `Button`, `NavigationSection`,
+is built from existing components (`Input`, `Button`, `NavigationSection`,
the Plugins-list row pattern) and `formatBinding`, which renders chords as glyphs
in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words
elsewhere (Ctrl+Shift+K), with a plain-words accessible label. A row whose chord
@@ -132,9 +132,14 @@ subtle text, no colour. Two pieces are
provisional and await a design pass: the key-combo `` chip
(`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control
(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately
-black-and-white on standard text, surface, border and radius tokens, live outside
+black-and-white: capture composes the shared Input with feature-owned sizing
+and keyboard handling; notices retain explicit alert text in neutral roles.
+The keycaps use standard text, surface, border and radius tokens. Both live outside
`src/shared/design-system/ui/`, and are marked with a `DESIGN PASS PENDING` file
comment and `data-design-pass="pending"` on their root so they are greppable.
+Capture keeps the shared keyboard-focus treatment. Escape cancels; Tab/Shift+Tab
+leave capture without saving, with an accessible instruction explaining the exit.
+Rows wrap their actions before the title collapses.
`tests/browser/shortcuts.spec.mjs` covers real key dispatch to Settings and actual
message/composer text, draft/node preservation, reset/limits/reload, modal/editor/
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 8dc847df5..d03334d90 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -555,7 +555,12 @@ stored override falls back to the registered default rather than stopping
dispatch. `formatBinding` in
`features/shortcuts/format.ts` renders any `KeyBinding` for the current platform;
plugins that print their own hint (the bundled terminal does) show their registered
-default because overrides are host state.
+default because overrides are host state. Xterm is the intentional local-first
+exception: before translating a keydown into PTY input, it synchronously forwards
+the original event to this same dispatcher through a private DOM handoff. Eligible
+app shortcuts (including live rebinds) win there; unhandled keys stay with xterm.
+No plugin shortcut API or preference access is added. Ordinary editors continue
+to handle keys before the window's bubbling dispatcher.
Known limitations. Capture and matching both use the logical `KeyboardEvent.key`.
On macOS an Option chord reports the composed character, so Option+K is stored
diff --git a/docs/terminal.md b/docs/terminal.md
index 4581c9cea..e62bbf1d1 100644
--- a/docs/terminal.md
+++ b/docs/terminal.md
@@ -7,6 +7,14 @@ hide the drawer without stopping work; reopening reattaches the same emulator an
shell. **End session** explicitly terminates it; **Restart** starts a fresh shell.
An exited shell remains visible until ended/restarted and never respawns automatically.
+Settings → Shortcuts can rebind the toggle. Before xterm translates a key into
+shell input, a private synchronous DOM handoff gives the original keydown to the
+host's existing dispatcher. Live overrides, eligibility and plugin lifetime stay
+host-owned; Terminal neither reads preferences nor reserves the old default.
+Handled keys are prevented once, not also sent to the shell. Ordinary message
+editors retain their local-first bubbling behavior. The launcher tooltip still
+shows the registered default rather than the effective binding.
+
## Run locally
From the agreed feature worktree, use `bin/just desktop`. Native commands require
@@ -97,8 +105,11 @@ Focused coverage lives in `src/bundled/terminal/sessions.test.ts`, the channel/p
composition tests, `src-tauri/src/terminal/tests.rs`, and the two
`tests/browser/terminal*.spec.mjs` journeys. The separate renderer journey exercises
real xterm input/Ctrl+C, resize, alternate-screen restoration, detach/reopen and
-app-chord release without starting a shell. The channel journey exercises actual
-browser launcher/shortcut absence, including plugin disable/re-enable. Desktop
+focused-terminal rebinding/reset/storage restore through the actual bundled
+registration and dispatcher, without starting a shell. The fixture replaces the
+native bridge and surrounding channel/relay services, not xterm or dispatch. The
+channel journey exercises actual browser launcher/shortcut absence, including
+plugin disable/re-enable. Desktop
registration is covered by `src/bundled/terminal/index.test.ts`; the actual desktop
header/dispatcher journey remains an attended acceptance check. Native tests exercise real PTYs,
public-context/environment fencing, limits, final output and teardown.
diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx
index 25915d220..b4ad5f685 100644
--- a/src/app/ShortcutSettings.test.tsx
+++ b/src/app/ShortcutSettings.test.tsx
@@ -413,10 +413,10 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
expect(screen.getByRole("alert")).toHaveTextContent(
"Ctrl+K is already used by Search Buzz (Buzz).",
);
- expect(screen.getByRole("alert")).toHaveClass("text-danger");
+ expect(screen.getByRole("alert")).toHaveClass("text-standard");
expect(input).toHaveAttribute(
"aria-describedby",
- screen.getByRole("alert").id,
+ expect.stringContaining(screen.getByRole("alert").id),
);
fireEvent.keyDown(input, { key: "j", ctrlKey: true });
expect(screen.getByRole("alert")).toHaveTextContent(
@@ -481,7 +481,7 @@ it("captures a chord, refuses conflicts and bare keys, applies overrides to the
fireEvent.keyDown(capture("Open Settings"), { key: "z", ctrlKey: true });
const warning = within(row("Open Settings")).getByRole("alert");
expect(warning).toHaveTextContent("message editor handles Ctrl+Z");
- expect(warning).toHaveClass("text-warning");
+ expect(warning).toHaveClass("text-standard");
expect(h.bindings.resolve("settings")).toEqual([{ key: "z", mod: true }]);
for (const [key, chord] of [
["y", "Ctrl+Shift+Y"],
@@ -599,7 +599,7 @@ it.each([true, false])(
"Saved. The message editor handles",
);
expect(within(row(title)).getByRole("alert")).toHaveClass(
- "text-warning",
+ "text-standard",
);
expect(h.bindings.resolve("settings")).toEqual([
{ key: "Enter", ...modifiers },
@@ -693,6 +693,7 @@ it("keeps a change active when saving fails and offers a retry", async () => {
});
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("could not be saved on this device");
+ expect(alert).toHaveClass("text-standard");
expect(
within(row("Toggle channel terminal")).getByText("Modified"),
).toBeInTheDocument();
@@ -898,3 +899,42 @@ it("keeps the host group distinct from a plugin whose manifest id is buzz", asyn
await h.dispose();
}
});
+
+it.each([false, true])(
+ "Tab leaves capture without saving (backwards=%s)",
+ async (shift) => {
+ const h = await harness();
+ const user = userEvent.setup();
+ try {
+ const run = vi.fn();
+ await h.contribute("example.tab", {
+ id: "tab",
+ title: "Plugin Tab",
+ binding: { key: "Tab", shift },
+ allowInEditable: true,
+ run,
+ });
+ render(
+ ,
+ );
+ await user.click(change("Open Settings"));
+ expect(capture("Open Settings")).toHaveAccessibleDescription(
+ /Press Escape to cancel, or Tab to leave/,
+ );
+ await user.tab({ shift });
+ expect(
+ screen.queryByRole("textbox", { name: /New shortcut/ }),
+ ).toBeNull();
+ expect(h.bindings.snapshot().overrides).toEqual({});
+ expect(h.runs.settings).not.toHaveBeenCalled();
+ expect(run).not.toHaveBeenCalled();
+ expect(document.activeElement).not.toBe(document.body);
+ } finally {
+ await h.dispose();
+ }
+ },
+);
diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx
index 1677f763a..26929fc4e 100644
--- a/src/app/ShortcutSettings.tsx
+++ b/src/app/ShortcutSettings.tsx
@@ -255,7 +255,7 @@ export function ShortcutSettings({
{groups.map((group) => (
-
+
{group.rows.map((row) => (
{error && (
-
+
{error}
@@ -356,7 +356,7 @@ function ShortcutRow({
className="flex flex-wrap items-center justify-between gap-3 px-5 py-3"
aria-labelledby={titleId}
>
-
+
{row.title}
@@ -375,13 +375,13 @@ function ShortcutRow({
{notice.message}
)}
-
+
{listening ? (
{
@@ -196,3 +199,38 @@ it("shows the live search shortcut in the trigger hint and follows a rebind", as
await root.fiber.dispose();
}
});
+
+it("renders a restored prototype-named search key and remains resettable", async () => {
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({ "global-search": { key: "constructor", mod: true } }),
+ );
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const bindings = createShortcutBindings(window);
+ const shortcuts = new ShortcutsService(root, window, bindings);
+ try {
+ render(
+ ,
+ );
+ const user = userEvent.setup();
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Search Buzz" })).toHaveFocus();
+ expect(await screen.findByRole("tooltip")).toHaveTextContent("constructor");
+ act(() => bindings.reset());
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Ctrl+K");
+ } finally {
+ bindings.dispose();
+ localStorage.clear();
+ await root.fiber.dispose();
+ }
+});
diff --git a/src/bundled/terminal/renderer.ts b/src/bundled/terminal/renderer.ts
index 2fd6a56bd..18db50ce9 100644
--- a/src/bundled/terminal/renderer.ts
+++ b/src/bundled/terminal/renderer.ts
@@ -3,6 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/600.css";
+import { forwardTerminalKey } from "../../features/shortcuts/terminal-key-event";
import { terminalAppearance } from "./appearance";
import { createSplash } from "./splash";
import styles from "./Terminal.module.css";
@@ -55,18 +56,14 @@ export function createScreen(
input(data, source);
});
terminal.onResize(({ cols, rows }) => resize(cols, rows));
- // Leave the app chord to its single dispatcher even in xterm's hidden textarea.
- terminal.attachCustomKeyEventHandler(
- (event) =>
- !(
- event.key.toLowerCase() === "j" &&
- !event.altKey &&
- !event.shiftKey &&
- (/Mac|iPhone|iPad/.test(navigator.platform)
- ? event.metaKey && !event.ctrlKey
- : event.ctrlKey && !event.metaKey)
- ),
- );
+ // Let the single dispatcher resolve live bindings before xterm writes PTY
+ // bytes. A handled event is prevented, so bubbling cannot dispatch it twice.
+ terminal.attachCustomKeyEventHandler((event) => {
+ const host = element.ownerDocument.defaultView;
+ return !disposed && element.isConnected && host
+ ? forwardTerminalKey(host, event)
+ : true;
+ });
let opened = false;
let disposed = false;
let receivedOutput = false;
diff --git a/src/features/shortcuts/KeyCaptureControl.module.css b/src/features/shortcuts/KeyCaptureControl.module.css
index e85f15a1b..303484753 100644
--- a/src/features/shortcuts/KeyCaptureControl.module.css
+++ b/src/features/shortcuts/KeyCaptureControl.module.css
@@ -1,12 +1,6 @@
-/* DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system. */
+/* DESIGN PASS PENDING: provisional capture composition; Input owns its appearance. */
.listening {
- min-width: var(--size-navigator);
- min-height: var(--size-control-sm);
- border: 1px solid var(--border-standard);
- border-radius: var(--radius-control);
- padding: 0 var(--space-3);
- background: var(--surface-inset);
- color: var(--text-standard);
- caret-color: transparent;
- cursor: default;
+ width: var(--size-navigator);
+ max-width: 100%;
+ min-width: 0;
}
diff --git a/src/features/shortcuts/KeyCaptureControl.tsx b/src/features/shortcuts/KeyCaptureControl.tsx
index 309a0a17f..85c4f1880 100644
--- a/src/features/shortcuts/KeyCaptureControl.tsx
+++ b/src/features/shortcuts/KeyCaptureControl.tsx
@@ -1,5 +1,6 @@
// DESIGN PASS PENDING: provisional black-and-white UI; not yet part of the design system.
-import { useEffect, useRef } from "react";
+import { useEffect, useId, useRef } from "react";
+import { Input } from "../../shared/design-system/ui/Input";
import type { KeyBinding } from "./bindings";
import styles from "./KeyCaptureControl.module.css";
@@ -47,45 +48,62 @@ export function KeyCaptureControl({
onCancel: () => void;
}) {
const input = useRef(null);
+ const instructionId = useId();
useEffect(() => {
input.current?.focus();
}, []);
return (
- {
- if (event.nativeEvent.isComposing || event.keyCode === 229) return;
- event.preventDefault();
- event.stopPropagation();
- if (MODIFIER_KEYS.has(event.key)) return;
- // Someone pressing Shift+Escape or Command+Escape is backing out, not
- // choosing a binding, so Escape never reaches onCapture.
- if (event.key === "Escape") {
- onCancel();
- return;
- }
- // AltGr can report Control+Alt, but the dispatcher always ignores it.
- if (event.getModifierState("AltGraph")) return;
- const mod = apple ? event.metaKey : event.ctrlKey;
- onCapture({
- binding: {
- key: event.key.length === 1 ? event.key.toLowerCase() : event.key,
- ...(mod && { mod }),
- ...(event.shiftKey && { shift: true }),
- ...(event.altKey && { alt: true }),
- },
- otherPrimary: apple ? event.ctrlKey : event.metaKey,
- });
- }}
- />
+
+
+ Press Escape to cancel, or Tab to leave shortcut capture.
+
+ {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
+ if (
+ event.key === "Tab" &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey
+ ) {
+ // Preserve native focus traversal, but not a plugin's Tab binding.
+ event.stopPropagation();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ if (MODIFIER_KEYS.has(event.key)) return;
+ // Someone pressing Shift+Escape or Command+Escape is backing out, not
+ // choosing a binding, so Escape never reaches onCapture.
+ if (event.key === "Escape") {
+ onCancel();
+ return;
+ }
+ // AltGr can report Control+Alt, but the dispatcher always ignores it.
+ if (event.getModifierState("AltGraph")) return;
+ const mod = apple ? event.metaKey : event.ctrlKey;
+ onCapture({
+ binding: {
+ key: event.key.length === 1 ? event.key.toLowerCase() : event.key,
+ ...(mod && { mod }),
+ ...(event.shiftKey && { shift: true }),
+ ...(event.altKey && { alt: true }),
+ },
+ otherPrimary: apple ? event.ctrlKey : event.metaKey,
+ });
+ }}
+ />
+
);
}
diff --git a/src/features/shortcuts/format.ts b/src/features/shortcuts/format.ts
index f7e111bc0..d016bd366 100644
--- a/src/features/shortcuts/format.ts
+++ b/src/features/shortcuts/format.ts
@@ -47,7 +47,10 @@ export function formatBinding(
binding: KeyBinding,
apple: boolean,
): FormattedBinding {
- const special = SPECIAL_KEYS[binding.key.toLowerCase()];
+ const normalized = binding.key.toLowerCase();
+ const special = Object.hasOwn(SPECIAL_KEYS, normalized)
+ ? SPECIAL_KEYS[normalized]
+ : undefined;
const key: Words = special ?? [
binding.key.length === 1 ? binding.key.toUpperCase() : binding.key,
binding.key.length === 1 ? binding.key.toUpperCase() : binding.key,
diff --git a/src/features/shortcuts/preferences.test.ts b/src/features/shortcuts/preferences.test.ts
index 7e619c5b7..2041853d4 100644
--- a/src/features/shortcuts/preferences.test.ts
+++ b/src/features/shortcuts/preferences.test.ts
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { formatBinding } from "./format";
import { afterEach, expect, it, vi } from "vitest";
import {
createShortcutBindings,
@@ -166,3 +167,22 @@ it("never hands out an inherited property: a prototype-named key is a stored bin
expect("constructor" in parseOverrides(null)).toBe(false);
bindings.dispose();
});
+
+it.each(["constructor", "__proto__", "toString", "hasOwnProperty"])(
+ "restores and formats a prototype-named key safely: %s",
+ (key) => {
+ localStorage.setItem(
+ SHORTCUT_BINDINGS_KEY,
+ JSON.stringify({ "global-search": { key, mod: true } }),
+ );
+ const bindings = createShortcutBindings(window);
+ try {
+ const restored = bindings.snapshot().overrides["global-search"];
+ if (!restored) throw new Error("Missing restored binding");
+ expect(formatBinding(restored, true).label).toBe(`Command ${key}`);
+ expect(formatBinding(restored, false).label).toBe(`Control ${key}`);
+ } finally {
+ bindings.dispose();
+ }
+ },
+);
diff --git a/src/features/shortcuts/service.test.ts b/src/features/shortcuts/service.test.ts
index ab6ee5a8b..72c9471f5 100644
--- a/src/features/shortcuts/service.test.ts
+++ b/src/features/shortcuts/service.test.ts
@@ -7,15 +7,14 @@ import type { PluginInfo } from "../../plugins/types";
import type { KeyBinding, Shortcut } from "./bindings";
function browser(apple = true) {
- const listeners = new Set<(event: KeyboardEvent) => void>();
+ const listeners = new Map void>();
let modal = false;
const host = {
navigator: { platform: apple ? "MacIntel" : "Linux x86_64" },
document: { querySelector: () => (modal ? {} : null) },
- addEventListener: (_: string, fn: (event: KeyboardEvent) => void) =>
- listeners.add(fn),
- removeEventListener: (_: string, fn: (event: KeyboardEvent) => void) =>
- listeners.delete(fn),
+ addEventListener: (type: string, fn: (event: KeyboardEvent) => void) =>
+ listeners.set(type, fn),
+ removeEventListener: (type: string) => listeners.delete(type),
} as unknown as Window;
return {
host,
@@ -41,7 +40,7 @@ function browser(apple = true) {
},
...init,
} as KeyboardEvent;
- for (const dispatch of listeners) dispatch(event);
+ listeners.get("keydown")?.(event);
return event;
},
};
diff --git a/src/features/shortcuts/service.ts b/src/features/shortcuts/service.ts
index 60dcb57e4..e3263b4a7 100644
--- a/src/features/shortcuts/service.ts
+++ b/src/features/shortcuts/service.ts
@@ -1,4 +1,5 @@
// FOUNDATION: One dispatcher; plugin readiness/lifetime remain owned by Cordis.
+import { TERMINAL_KEY_EVENT } from "./terminal-key-event";
import { Service, type Context } from "@deepseek-ai/cordis";
import {
createContributions,
@@ -107,10 +108,15 @@ export class ShortcutsService extends Service implements Shortcuts {
console.error(`Shortcut failed: ${shortcut.id}`, error);
}
};
+ // Only xterm needs an early handoff: ordinary editors still handle keys first.
+ const terminalKey = (event: Event) =>
+ dispatch((event as CustomEvent).detail);
ctx.effect(() => {
+ host?.addEventListener(TERMINAL_KEY_EVENT, terminalKey);
host?.addEventListener("keydown", dispatch);
return () => {
host?.removeEventListener("keydown", dispatch);
+ host?.removeEventListener(TERMINAL_KEY_EVENT, terminalKey);
this.hostBindings.clear();
this.publishHost();
};
diff --git a/src/features/shortcuts/terminal-key-event.test.ts b/src/features/shortcuts/terminal-key-event.test.ts
new file mode 100644
index 000000000..3828991a2
--- /dev/null
+++ b/src/features/shortcuts/terminal-key-event.test.ts
@@ -0,0 +1,103 @@
+// @vitest-environment jsdom
+import { Context } from "@deepseek-ai/cordis";
+import { afterEach, expect, it, vi } from "vitest";
+import { ShortcutsService } from "./service";
+import { createShortcutBindings } from "./preferences";
+import { forwardTerminalKey } from "./terminal-key-event";
+
+afterEach(() => {
+ document.body.replaceChildren();
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+it("hands the original editable event to live dispatch exactly once, respecting guards and disposal", async () => {
+ const ctx = new Context();
+ ctx.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const bindings = createShortcutBindings(window);
+ const shortcuts = new ShortcutsService(ctx, window, bindings);
+ const run = vi.fn();
+ let available = true;
+ const registration = {
+ id: "toggle",
+ title: "Terminal",
+ binding: { key: "j", mod: true },
+ run,
+ when: () => available,
+ };
+ let unregister = shortcuts.registerHost(registration);
+ const input = document.createElement("textarea");
+ document.body.append(input);
+ input.addEventListener("keydown", (event) =>
+ forwardTerminalKey(window, event),
+ );
+ const apple = /Mac|iPhone|iPad/.test(navigator.platform);
+ const key = (init: KeyboardEventInit = {}) => {
+ const event = new KeyboardEvent("keydown", {
+ key: "u",
+ metaKey: apple,
+ ctrlKey: !apple,
+ bubbles: true,
+ cancelable: true,
+ ...init,
+ });
+ input.dispatchEvent(event);
+ return event;
+ };
+ try {
+ bindings.set("toggle", { key: "u", mod: true });
+ expect(key().defaultPrevented).toBe(false); // Original textarea path, not window.
+ unregister();
+ unregister = shortcuts.registerHost({
+ ...registration,
+ allowInEditable: true,
+ });
+ expect(key().defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(1); // No second execution while bubbling.
+ expect(key({ repeat: true }).defaultPrevented).toBe(true);
+ available = false;
+ expect(key().defaultPrevented).toBe(false);
+ available = true;
+ const modal = document.createElement("dialog");
+ modal.setAttribute("open", "");
+ document.body.append(modal);
+ expect(key().defaultPrevented).toBe(false);
+ modal.remove();
+ for (const init of [
+ { isComposing: true },
+ { keyCode: 229 },
+ { modifierAltGraph: true },
+ ]) {
+ expect(key(init).defaultPrevented).toBe(false);
+ }
+ const prevented = new KeyboardEvent("keydown", {
+ key: "u",
+ cancelable: true,
+ });
+ prevented.preventDefault();
+ expect(forwardTerminalKey(window, prevented)).toBe(false);
+ for (const type of ["keyup", "keypress"]) {
+ expect(
+ forwardTerminalKey(
+ window,
+ new KeyboardEvent(type, { key: "u", ctrlKey: true }),
+ ),
+ ).toBe(true);
+ }
+ expect(run).toHaveBeenCalledTimes(1);
+ bindings.reset();
+ expect(key().defaultPrevented).toBe(false);
+ expect(key({ key: "j" }).defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(2);
+ await ctx.fiber.dispose();
+ expect(key({ key: "j" }).defaultPrevented).toBe(false);
+ expect(run).toHaveBeenCalledTimes(2);
+ } finally {
+ unregister();
+ bindings.dispose();
+ await ctx.fiber.dispose();
+ }
+});
diff --git a/src/features/shortcuts/terminal-key-event.ts b/src/features/shortcuts/terminal-key-event.ts
new file mode 100644
index 000000000..0a2ab0717
--- /dev/null
+++ b/src/features/shortcuts/terminal-key-event.ts
@@ -0,0 +1,17 @@
+/** Private DOM handoff for xterm, not an injected/exported plugin capability. */
+export const TERMINAL_KEY_EVENT = "buzz:terminal-keydown";
+
+/**
+ * xterm calls this before translating a key into PTY input. Keep the original
+ * event (and its editable/modal composed path) while synchronously consulting
+ * the one dispatcher; never redispatch an in-flight KeyboardEvent.
+ */
+export function forwardTerminalKey(host: Window, event: KeyboardEvent) {
+ if (event.type !== "keydown") return true;
+ if (!event.defaultPrevented) {
+ host.dispatchEvent(
+ new CustomEvent(TERMINAL_KEY_EVENT, { detail: event }),
+ );
+ }
+ return !event.defaultPrevented;
+}
diff --git a/tests/browser/shortcuts.spec.mjs b/tests/browser/shortcuts.spec.mjs
index d3046c748..40e0e7038 100644
--- a/tests/browser/shortcuts.spec.mjs
+++ b/tests/browser/shortcuts.spec.mjs
@@ -318,3 +318,132 @@ test("Settings → Shortcuts rebinds a plugin shortcut live, blocks host conflic
await page.keyboard.press(`${modifier}+Shift+k`);
await expect(count).toHaveText("Shortcut count: 2");
});
+
+// Browser-only: actual Settings gutters, flex wrapping and keyboard focus paint
+// cannot be established by jsdom. Reuse one app for the layout/theme samples.
+test("shortcut capture keeps its title and keyboard focus visible, with neutral notices and a Tab exit", async ({
+ page,
+ app,
+}, testInfo) => {
+ await page.goto(app.origin);
+ const modifier = await mod(page);
+ await button(page, "Search Buzz").waitFor();
+ await page.keyboard.press(`${modifier}+,`);
+ await button(page, "Shortcuts").click();
+ const row = page.getByRole("article", { name: "Open Settings", exact: true });
+ const change = button(row, "Change shortcut for Open Settings");
+ const input = row.getByRole("textbox", {
+ name: "New shortcut for Open Settings",
+ });
+ const title = row.getByRole("heading", {
+ name: "Open Settings",
+ exact: true,
+ });
+ for (const mode of ["light", "dark"]) {
+ await page.evaluate((mode) => {
+ document.documentElement.dataset.colorMode = mode;
+ }, mode);
+ for (const width of [390, 800, 1280]) {
+ await page.setViewportSize({ width, height: 950 });
+ for (const scale of [1, 2]) {
+ await page.evaluate(
+ (scale) =>
+ document.documentElement.style.setProperty(
+ "--buzz-text-scale",
+ String(scale),
+ ),
+ scale,
+ );
+ // Enter capture through real keyboard navigation, not a pointer click.
+ await change.focus();
+ await page.keyboard.press("Shift+Tab");
+ await page.keyboard.press("Tab");
+ await expect(change).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(input).toBeFocused();
+ await expect(input).toHaveCSS("outline-style", "solid");
+ await expect(input).toHaveCSS("outline-width", "2px");
+ await expect(input).toHaveAccessibleDescription(
+ /Press Escape to cancel/,
+ );
+ const focusColor = await input.evaluate((element) => {
+ const probe = document.createElement("span");
+ probe.style.color = "var(--border-focus)";
+ element.parentElement.append(probe);
+ const color = getComputedStyle(probe).color;
+ probe.remove();
+ return color;
+ });
+ await expect(input).toHaveCSS("outline-color", focusColor);
+ await page.evaluate(() => document.fonts.ready);
+ const headingBox = await title.boundingBox();
+ const inputBox = await input.boundingBox();
+ const rowBox = await row.boundingBox();
+ const textBoxes = await title.evaluate((element) => {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ return [...range.getClientRects()].map(({ x, y, width, height }) => ({
+ x,
+ y,
+ width,
+ height,
+ }));
+ });
+ for (const box of textBoxes) {
+ expect(box.x + box.width).toBeLessThanOrEqual(
+ headingBox.x + headingBox.width + 1,
+ );
+ expect(
+ box.y + box.height <= inputBox.y || box.x + box.width <= inputBox.x,
+ ).toBe(true);
+ }
+ expect(
+ headingBox.y + headingBox.height <= inputBox.y ||
+ headingBox.x + headingBox.width <= inputBox.x,
+ ).toBe(true);
+ for (const box of [
+ inputBox,
+ await button(row, "Cancel changing Open Settings").boundingBox(),
+ ]) {
+ expect(box.x).toBeGreaterThanOrEqual(rowBox.x);
+ expect(box.x + box.width).toBeLessThanOrEqual(
+ rowBox.x + rowBox.width,
+ );
+ }
+ // A rejected chord stays in capture, announces why, and stays neutral.
+ await page.keyboard.press(`${modifier}+k`);
+ const alert = row.getByRole("alert");
+ await expect(alert).toContainText("already used by Search Buzz");
+ await expect(input).toHaveAccessibleDescription(
+ /already used by Search Buzz/,
+ );
+ await expect(alert).toHaveCSS(
+ "color",
+ await title.evaluate((el) => getComputedStyle(el).color),
+ );
+ if (scale === 1)
+ await page.screenshot({
+ path: testInfo.outputPath(`capture-${mode}-${width}.png`),
+ });
+ await page.keyboard.press("Tab");
+ await expect(input).toHaveCount(0);
+ await expect(change).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(input).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(change).toBeFocused();
+ }
+ }
+ await page.keyboard.press("Enter");
+ await expect(input).toBeFocused();
+ await page.keyboard.press(`${modifier}+z`);
+ await expect(row.getByRole("alert")).toContainText(
+ "message editor handles",
+ );
+ await expect(row.getByRole("alert")).toHaveCSS(
+ "color",
+ await title.evaluate((el) => getComputedStyle(el).color),
+ );
+ await button(row, "Reset shortcut for Open Settings").click();
+ }
+});
diff --git a/tests/browser/terminal-renderer.spec.mjs b/tests/browser/terminal-renderer.spec.mjs
index 1b4930a9d..7b7aa1003 100644
--- a/tests/browser/terminal-renderer.spec.mjs
+++ b/tests/browser/terminal-renderer.spec.mjs
@@ -3,7 +3,7 @@ import { createServer } from "./vite-server.mjs";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
-test("real xterm retains output across detach, handles input and resize, and releases app shortcut", async ({
+test("real xterm retains output across detach, handles input and resize, and does not reserve absent shortcuts", async ({
page,
}) => {
const root = fileURLToPath(new URL("../../", import.meta.url));
@@ -191,21 +191,10 @@ test("real xterm retains output across detach, handles input and resize, and rel
.poll(() => xterm.evaluate((el) => getComputedStyle(el).backgroundColor))
.toBe(lightBackground);
await page.locator(".xterm-helper-textarea").focus();
- const before = await input.textContent();
- const modifier = (await page.evaluate(() =>
- /Mac|iPhone|iPad/.test(navigator.platform),
- ))
- ? "Meta"
- : "Control";
- await page.evaluate(() => {
- window.terminalChord = false;
- window.addEventListener("keydown", (e) => {
- if (e.key === "j") window.terminalChord = !e.defaultPrevented;
- });
- });
- await page.keyboard.press(`${modifier}+j`);
- expect(await page.evaluate(() => window.terminalChord)).toBe(true);
- await expect(input).toHaveText(before);
+ // With no dispatcher/registration, even the old toggle chord belongs to
+ // the terminal. The plugin/dispatcher handoff is covered below.
+ await page.keyboard.press("Control+j");
+ await expect(input).toHaveText('"hello\\u0003\\n"');
// Fresh dark startup, bounded static splash (including reduced motion).
await page.emulateMedia({ reducedMotion: "reduce" });
await page.reload();
@@ -553,3 +542,121 @@ test("real xterm replies survive scope switches while stale input and retired wr
await server.close();
}
});
+
+// Browser-only: real xterm's capture-phase handler can write PTY bytes and stop
+// propagation. Exercise the actual bundled registration, store and dispatcher.
+test("focused terminal follows rebind, restore and reset without swallowing the chord or writing it to the shell", async ({
+ page,
+}) => {
+ // Force the non-Apple mapping even on macOS: Ctrl+U is xterm's line kill.
+ await page.addInitScript(() =>
+ Object.defineProperty(navigator, "platform", {
+ value: "Linux x86_64",
+ configurable: true,
+ }),
+ );
+ const server = await createServer({
+ root: fileURLToPath(new URL("../../", import.meta.url)),
+ configFile: false,
+ envFile: false,
+ plugins: [react()],
+ server: { host: "127.0.0.1", port: 0 },
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ try {
+ await server.listen();
+ await page.goto(
+ `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal-panel.html`,
+ );
+ const launcher = page.getByRole("button", {
+ name: "Toggle channel terminal",
+ exact: true,
+ });
+ const drawer = page.getByRole("region", { name: "Terminal drawer" });
+ const textarea = drawer.locator(".xterm-helper-textarea");
+ const settings = page.getByRole("button", {
+ name: "Shortcut settings",
+ exact: true,
+ });
+ const row = page.getByRole("article", {
+ name: "Toggle channel terminal",
+ exact: true,
+ });
+ await settings.click();
+ await row
+ .getByRole("button", {
+ name: "Change shortcut for Toggle channel terminal",
+ exact: true,
+ })
+ .click();
+ await page.keyboard.press("Control+u");
+ await expect(row.getByText("Modified")).toBeVisible();
+ await launcher.click();
+ await expect(drawer.locator(".xterm-rows")).toContainText(
+ "FIXTURE_SHELL_READY",
+ );
+ await page.evaluate(() => {
+ window.retainedTerminal = document.querySelector(".xterm");
+ });
+ await expect(textarea).toBeFocused();
+ await page.keyboard.type("hello");
+ await page.keyboard.press("Control+c");
+ await expect
+ .poll(() => page.evaluate(() => window.terminalPanel.written()))
+ .toBe("hello\u0003");
+ const toggles = await page.evaluate(() => window.terminalPanel.toggles());
+ await page.keyboard.press("Control+u");
+ await expect(drawer).toHaveCount(0);
+ expect(await page.evaluate(() => window.terminalPanel.toggles())).toBe(
+ toggles + 1,
+ );
+ expect(await page.evaluate(() => window.terminalPanel.written())).toBe(
+ "hello\u0003",
+ );
+ await launcher.click();
+ await expect(textarea).toBeFocused();
+ expect(
+ await page.evaluate(
+ () => window.retainedTerminal === document.querySelector(".xterm"),
+ ),
+ ).toBe(true);
+ await page.keyboard.press("Control+j"); // Old binding now reaches the shell.
+ await expect
+ .poll(() => page.evaluate(() => window.terminalPanel.written()))
+ .toBe("hello\u0003\n");
+ await row
+ .getByRole("button", {
+ name: "Reset shortcut for Toggle channel terminal",
+ exact: true,
+ })
+ .click();
+ await textarea.focus();
+ await page.keyboard.press("Control+u"); // Reset releases Ctrl+U again.
+ await expect
+ .poll(() => page.evaluate(() => window.terminalPanel.written()))
+ .toBe("hello\u0003\n\u0015");
+ await page.keyboard.press("Control+j");
+ await expect(drawer).toHaveCount(0);
+ // Restore from device storage; modified Enter would otherwise write CR.
+ await row
+ .getByRole("button", {
+ name: "Change shortcut for Toggle channel terminal",
+ exact: true,
+ })
+ .click();
+ await page.keyboard.press("Control+Enter");
+ await expect(row.getByRole("alert")).toContainText("Saved");
+ await page.reload();
+ await launcher.click();
+ await expect(textarea).toBeFocused();
+ await page.keyboard.press("Control+Enter");
+ await expect(drawer).toHaveCount(0);
+ expect(await page.evaluate(() => window.terminalPanel.toggles())).toBe(2);
+ expect(await page.evaluate(() => window.terminalPanel.written())).toBe("");
+ await page.evaluate(() => window.terminalPanel.dispose());
+ expect(errors).toEqual([]);
+ } finally {
+ await server.close();
+ }
+});
diff --git a/tests/fixtures/terminal-panel.tsx b/tests/fixtures/terminal-panel.tsx
index 565912ad7..d8963618c 100644
--- a/tests/fixtures/terminal-panel.tsx
+++ b/tests/fixtures/terminal-panel.tsx
@@ -1,3 +1,8 @@
+import { Context } from "@deepseek-ai/cordis";
+import { ShortcutsService } from "../../src/features/shortcuts/service";
+import { createShortcutBindings } from "../../src/features/shortcuts/preferences";
+import { ShortcutSettings } from "../../src/app/ShortcutSettings";
+import type { PluginManager } from "../../src/plugins/manager";
import { useState } from "react";
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter/wght.css";
@@ -27,8 +32,20 @@ let failClose = false;
let closing: Promise | undefined;
let releaseClose: (() => void) | undefined;
let closePending = false;
+let written = "";
+let toggles = 0;
+const bindings = createShortcutBindings(window);
+const scope = new Context();
+scope.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+});
+const shortcuts = new ShortcutsService(scope, window, bindings);
Object.assign(window, {
terminalPanel: {
+ written: () => written,
+ toggles: () => toggles,
+ dispose: () => scope.fiber.dispose(),
holdClose() {
closing = new Promise((resolve) => {
releaseClose = resolve;
@@ -55,7 +72,9 @@ const bridge: TerminalBridge = {
output = false;
return { data, exited: false };
},
- write: async () => {},
+ write: async (_owner, _id, data) => {
+ written += data;
+ },
resize: async () => {},
close: async () => {
closePending = true;
@@ -71,22 +90,27 @@ const bridge: TerminalBridge = {
};
Object.assign(nativeBridge, bridge);
let contribution: Panel | undefined;
-apply({
- panels: {
- register: (panel: Panel) => {
- contribution = panel;
- },
+scope.provide("panels", {
+ register: (panel: Panel) => {
+ contribution = panel;
},
- shortcuts: { register: () => {} },
- effect: () => {},
- relay: {
- snapshot: () => ({
- scope: context.scope,
- viewer: context.viewer,
- status: "ready",
- }),
- },
-} as unknown as Parameters[0]);
+} as unknown as typeof scope.panels);
+scope.provide("relay", {
+ snapshot: () => ({
+ scope: context.scope,
+ viewer: context.viewer,
+ status: "ready",
+ }),
+} as unknown as typeof scope.relay);
+await scope
+ .extend({ pluginOwner: { id: "buzz.terminal", revision: "fixture" } })
+ .plugin((ctx) => apply(ctx))
+ .await();
+const pluginState = { configuration: { status: "loading" } };
+const plugins = {
+ subscribe: () => () => {},
+ snapshot: () => pluginState,
+} as unknown as Pick;
if (!contribution?.channelLauncher)
throw new Error("Missing terminal contribution");
const Launcher = contribution.channelLauncher;
@@ -94,8 +118,19 @@ const Content = contribution.component;
function Fixture() {
useKeyboardFocusVisibility();
const [visible, show] = useState(false);
+ const [settings, showSettings] = useState(false);
return (
<>
+ showSettings((value) => !value)}>
+ Shortcut settings
+
+ {settings && (
+
+ )}
{
@@ -132,7 +167,10 @@ function Fixture() {
context={context}
pressed={visible}
available={() => true}
- toggle={() => show((value) => !value)}
+ toggle={() => {
+ toggles++;
+ show((value) => !value);
+ }}
/>
{visible && (