Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,35 @@ 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 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 (`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
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 `<kbd>` chip
(`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control
(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately
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/
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.

Expand Down
58 changes: 56 additions & 2 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down Expand Up @@ -510,10 +512,62 @@ 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 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.

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`
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, 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. 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
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.
15 changes: 13 additions & 2 deletions docs/terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions examples/plugins/shortcut-counter/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
16 changes: 16 additions & 0 deletions src/app/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PaletteIcon,
BellIcon,
ChatCircleIcon,
KeyboardIcon,
WrenchIcon,
} from "../shared/design-system/icons/index";
import type { PluginManager } from "../plugins/manager";
Expand All @@ -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";

Expand All @@ -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 },
];
Expand All @@ -49,13 +54,17 @@ export function Settings({
plugins,
communities,
appearance,
shortcuts,
shortcutBindings,
notifications,
navigation,
onSection,
}: {
plugins: PluginManager;
communities: Communities;
appearance: Appearance;
shortcuts: ShortcutsService;
shortcutBindings: ShortcutBindings;
notifications: NotificationsService;
navigation?:
| import("../features/navigation/service").PageNavigation
Expand Down Expand Up @@ -117,6 +126,13 @@ export function Settings({
<div hidden={selected !== "appearance"}>
<AppearanceSettings appearance={appearance} />
</div>
<div hidden={selected !== "shortcuts"}>
<ShortcutSettings
shortcuts={shortcuts}
bindings={shortcutBindings}
plugins={plugins}
/>
</div>
<div hidden={selected !== "messages"}>
<MessageSettings />
</div>
Expand Down
Loading
Loading