feat(plugins): add keybindings and modal window capabilities (#1895)

* feat(plugins): add keybindings and modal window capabilities

Add two independent plugin UI capabilities.

Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.

Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.

Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address review feedback on keybinding dispatch and validation

Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings

The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): make core shortcuts always win over plugin keybindings

Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.

Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard

cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plugins): document keybindings and modal window plugin APIs

Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-24 11:45:28 +01:00
committed by GitHub
parent e824dcca87
commit 99dfbbde58
44 changed files with 2692 additions and 54 deletions
@@ -34,6 +34,10 @@ webhooks:
ui:
bundle: "/ui/bundle.js"
keybindings:
- id: open-demo
default: mod+shift+j
description: Open the demo modal
config_schema:
type: object
@@ -4,8 +4,10 @@
* imports, no bundled React — that calls `window.registerKandevPlugin(id,
* plugin)` at evaluation time and, on `initialize(registry, host)`,
* registers a nav item, a top-level route, a `task-sidebar` slot component,
* a `main-top-bar` slot component, and a `task.created` WS handler. Uses
* only host.React/host.jsx.
* a `main-top-bar` slot component, a `task.created` WS handler, and the
* `open-demo` keybinding (declared in manifest.yaml's `ui.keybindings`,
* default `mod+shift+k`) which opens a `host.openModal(...)` demo modal.
* Uses only host.React/host.jsx.
*
* The task-created counter lives in module scope (not component state) with
* a tiny listener set, so it survives across route navigations (the page
@@ -91,6 +93,21 @@
registry.registerWsHandler("task.created", function () {
incrementCount();
});
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
return jsx(
"div",
{ id: "hello-demo-modal", "data-testid": "hello-demo-modal" },
"Hello from the plugin modal",
);
}
host.openModal({
title: "Demo Modal",
content: DemoModalContent,
size: "md",
});
});
},
});
})();
@@ -2,6 +2,7 @@ package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -173,6 +174,74 @@ func TestListHandlerReturnsInstalledPlugins(t *testing.T) {
}
}
// TestListHandlerExposesUIKeybindings confirms that ui.keybindings declared
// in the manifest round-trips through GET /api/plugins into the JSON each
// plugin entry carries for the frontend (record.ui.keybindings, since
// store.Record embeds manifest.Manifest inline).
func TestListHandlerExposesUIKeybindings(t *testing.T) {
router, svc := newTestRouter(t)
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: kandev-plugin-keybound
api_version: 1
version: "1.0.0"
display_name: Test Plugin
capabilities:
events: ["task.*"]
runtime:
type: binary
executables:
%s: server/plugin
ui:
bundle: "/ui/bundle.js"
keybindings:
- id: open-demo
default: mod+shift+k
description: Open the demo modal
`, platformKey)
var buf bytes.Buffer
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
"ui/bundle.js": []byte("export default {};"),
}); err != nil {
t.Fatalf("WritePackage: %v", err)
}
if _, err := svc.Install(context.Background(), &buf); err != nil {
t.Fatalf("Install: %v", err)
}
rec := doRequest(router, http.MethodGet, "/api/plugins", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
Plugins []struct {
UI struct {
Keybindings []struct {
ID string `json:"id"`
Default string `json:"default"`
Description string `json:"description"`
} `json:"keybindings"`
} `json:"ui"`
} `json:"plugins"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(body.Plugins) != 1 {
t.Fatalf("len(plugins) = %d, want 1", len(body.Plugins))
}
kbs := body.Plugins[0].UI.Keybindings
if len(kbs) != 1 {
t.Fatalf("len(ui.keybindings) = %d, want 1", len(kbs))
}
if kbs[0].ID != "open-demo" || kbs[0].Default != "mod+shift+k" || kbs[0].Description != "Open the demo modal" {
t.Fatalf("ui.keybindings[0] = %+v, want id=open-demo default=mod+shift+k description=%q", kbs[0], "Open the demo modal")
}
}
func TestGetHandlerMissingReturns404(t *testing.T) {
router, _ := newTestRouter(t)
rec := doRequest(router, http.MethodGet, "/api/plugins/missing", "", nil)
@@ -92,9 +92,10 @@ type Webhook struct {
// optional/secondary when a bundle is declared: native routes come from the
// bundle at runtime.
type UISection struct {
Pages []UIPage `yaml:"pages,omitempty" json:"pages,omitempty"`
Bundle string `yaml:"bundle,omitempty" json:"bundle,omitempty"`
Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"`
Pages []UIPage `yaml:"pages,omitempty" json:"pages,omitempty"`
Bundle string `yaml:"bundle,omitempty" json:"bundle,omitempty"`
Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"`
Keybindings []UIKeybinding `yaml:"keybindings,omitempty" json:"keybindings,omitempty"`
}
// UIPage is a single UI page contributed by the plugin.
@@ -105,6 +106,18 @@ type UIPage struct {
Surface string `yaml:"surface" json:"surface"`
}
// UIKeybinding is a single keyboard shortcut a plugin's UI bundle wants
// kandev to register and dispatch on its behalf. ID is plugin-local (unique
// within the plugin, not globally); Default is a combo like "mod+shift+k"
// (see parseKeybindingCombo in validate.go for the accepted grammar).
// Keybindings are only meaningful alongside ui.bundle, since the plugin's JS
// bundle is what handles the dispatched event.
type UIKeybinding struct {
ID string `yaml:"id" json:"id"`
Default string `yaml:"default" json:"default"`
Description string `yaml:"description" json:"description"`
}
// Parse decodes a plugin manifest from YAML bytes. It does not validate the
// result; callers should call (*Manifest).Validate() afterward.
func Parse(data []byte) (*Manifest, error) {
@@ -335,6 +335,155 @@ func TestValidate_ManifestWithBundleAndNoPagesPasses(t *testing.T) {
}
}
func TestValidate_ValidKeybindingPasses(t *testing.T) {
m := validManifest(t)
m.UI.Bundle = "/ui/bundle.js"
m.UI.Keybindings = []UIKeybinding{
{ID: "open-demo", Default: "mod+shift+k", Description: "Open the demo modal"},
}
if err := m.Validate(); err != nil {
t.Fatalf("Validate() unexpected error: %v", err)
}
}
func TestValidate_RejectsDuplicateKeybindingID(t *testing.T) {
m := validManifest(t)
m.UI.Bundle = "/ui/bundle.js"
m.UI.Keybindings = []UIKeybinding{
{ID: "open-demo", Default: "mod+k", Description: "Open the demo modal"},
{ID: "open-demo", Default: "mod+j", Description: "Open a different modal"},
}
err := m.Validate()
if err == nil {
t.Fatal("Validate() expected error for duplicate ui.keybindings id, got nil")
}
if !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "duplicate")
}
}
func TestValidate_RejectsEmptyKeybindingDescription(t *testing.T) {
m := validManifest(t)
m.UI.Bundle = "/ui/bundle.js"
m.UI.Keybindings = []UIKeybinding{
{ID: "open-demo", Default: "mod+k", Description: ""},
}
err := m.Validate()
if err == nil {
t.Fatal("Validate() expected error for empty ui.keybindings description, got nil")
}
if !strings.Contains(err.Error(), "description") {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "description")
}
}
func TestValidate_RejectsKeybindingWithoutBundle(t *testing.T) {
m := validManifest(t)
m.UI.Bundle = ""
m.UI.Keybindings = []UIKeybinding{
{ID: "open-demo", Default: "mod+k", Description: "Open the demo modal"},
}
err := m.Validate()
if err == nil {
t.Fatal("Validate() expected error for ui.keybindings without ui.bundle, got nil")
}
if !strings.Contains(err.Error(), "ui.bundle") {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "ui.bundle")
}
}
func TestParseKeybindingCombo_ValidCombos(t *testing.T) {
valid := []string{"k", "mod+k", "mod+shift+k", "ctrl+alt+f1", "arrowup", "cmd+enter", " mod + K "}
for _, combo := range valid {
if err := parseKeybindingCombo(combo); err != nil {
t.Errorf("parseKeybindingCombo(%q) unexpected error: %v", combo, err)
}
}
}
func TestParseKeybindingCombo_RejectsEmpty(t *testing.T) {
if err := parseKeybindingCombo(""); err == nil {
t.Fatal("parseKeybindingCombo(\"\") expected error, got nil")
}
}
func TestParseKeybindingCombo_RejectsTwoNonModifierKeys(t *testing.T) {
err := parseKeybindingCombo("mod+k+j")
if err == nil {
t.Fatal("parseKeybindingCombo(\"mod+k+j\") expected error, got nil")
}
if !strings.Contains(err.Error(), "exactly one") {
t.Fatalf("parseKeybindingCombo() error = %q, want substring %q", err.Error(), "exactly one")
}
}
func TestParseKeybindingCombo_RejectsNoNonModifierKey(t *testing.T) {
err := parseKeybindingCombo("mod+shift")
if err == nil {
t.Fatal("parseKeybindingCombo(\"mod+shift\") expected error, got nil")
}
if !strings.Contains(err.Error(), "exactly one") {
t.Fatalf("parseKeybindingCombo() error = %q, want substring %q", err.Error(), "exactly one")
}
}
func TestParseKeybindingCombo_RejectsUnknownModifier(t *testing.T) {
err := parseKeybindingCombo("hyper+k")
if err == nil {
t.Fatal("parseKeybindingCombo(\"hyper+k\") expected error, got nil")
}
if !strings.Contains(err.Error(), "unknown token") {
t.Fatalf("parseKeybindingCombo() error = %q, want substring %q", err.Error(), "unknown token")
}
}
// "control" and "super" are undocumented aliases for ctrl/cmd that the
// backend used to accept even though docs/plans/plugins/PLUGIN-API.md only
// documents mod|ctrl|cmd|meta|alt|option|shift. Dropped for consistency with
// the public contract and the frontend parser.
func TestParseKeybindingCombo_RejectsUndocumentedModifierAliases(t *testing.T) {
for _, combo := range []string{"control+k", "super+k"} {
err := parseKeybindingCombo(combo)
if err == nil {
t.Fatalf("parseKeybindingCombo(%q) expected error, got nil", combo)
}
if !strings.Contains(err.Error(), "unknown token") {
t.Fatalf("parseKeybindingCombo(%q) error = %q, want substring %q", combo, err.Error(), "unknown token")
}
}
}
// A manifest default like "mod+shift+slash" or "shift+1" is stored/compared
// against KeyboardEvent.key, but a physically-held Shift means the browser
// reports the shifted character ("?", "!") instead of the token validated
// here — so the combo could never actually dispatch. Reject it at
// registration time instead.
func TestParseKeybindingCombo_RejectsShiftWithAlteredKey(t *testing.T) {
for _, combo := range []string{"shift+1", "mod+shift+slash", "shift+9", "shift+minus"} {
err := parseKeybindingCombo(combo)
if err == nil {
t.Fatalf("parseKeybindingCombo(%q) expected error, got nil", combo)
}
if !strings.Contains(err.Error(), "shift") {
t.Fatalf("parseKeybindingCombo(%q) error = %q, want substring %q", combo, err.Error(), "shift")
}
}
}
// Shift is fine with letters (case is normalized before matching), named
// keys, and function keys, since Shift doesn't change their KeyboardEvent.key.
func TestParseKeybindingCombo_AllowsShiftWithUnaffectedKeys(t *testing.T) {
for _, combo := range []string{"shift+k", "mod+shift+tab", "shift+arrowup", "ctrl+shift+f1"} {
if err := parseKeybindingCombo(combo); err != nil {
t.Errorf("parseKeybindingCombo(%q) unexpected error: %v", combo, err)
}
}
}
func TestHasUIBundle_TrueWhenBundleSet(t *testing.T) {
m := validManifest(t)
m.UI.Bundle = "/ui/bundle.js"
@@ -30,6 +30,87 @@ var validUISurfaces = map[string]bool{
"main-nav": true,
}
// keybindingIDPattern matches the required UIKeybinding.ID shape: lowercase
// alphanumerics and hyphens, starting with a lowercase letter or digit. IDs
// are plugin-local (unique within the plugin's own keybindings list, not
// globally), so this is deliberately simpler than idPattern.
var keybindingIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// keybindingModifiers are the accepted modifier tokens in a keybinding combo
// (case-insensitive; matched after lowercasing). "mod" is the
// platform-agnostic primary modifier (Cmd on macOS, Ctrl elsewhere) that the
// frontend dispatcher resolves at runtime; the rest are explicit aliases.
// This set must match the public contract in docs/plans/plugins/PLUGIN-API.md
// and the frontend parser (apps/web/lib/keyboard/parse-combo.ts) exactly —
// "control" and "super" are deliberately NOT accepted here even though they'd
// be easy aliases, because the documented grammar only lists
// mod|ctrl|cmd|meta|alt|option|shift.
var keybindingModifiers = map[string]bool{
"mod": true,
"ctrl": true,
"cmd": true,
"meta": true,
"alt": true,
"option": true,
"shift": true,
}
// keybindingKeys are the accepted non-modifier key tokens in a keybinding
// combo (case-insensitive; matched after lowercasing). This is a
// conservative, documented vocabulary — not an exhaustive KeyboardEvent.key
// mirror — covering: single alphanumeric characters, function keys F1-F12,
// and the common named keys plugins are expected to bind.
var keybindingKeys = buildKeybindingKeySet()
func buildKeybindingKeySet() map[string]bool {
keys := map[string]bool{
"enter": true, "escape": true, "esc": true, "tab": true, "space": true,
"backspace": true, "delete": true, "insert": true,
"arrowup": true, "arrowdown": true, "arrowleft": true, "arrowright": true,
"up": true, "down": true, "left": true, "right": true,
"home": true, "end": true, "pageup": true, "pagedown": true,
"comma": true, "period": true, "slash": true, "backslash": true,
"semicolon": true, "quote": true, "minus": true, "equal": true,
"bracketleft": true, "bracketright": true, "backquote": true,
}
for c := 'a'; c <= 'z'; c++ {
keys[string(c)] = true
}
for c := '0'; c <= '9'; c++ {
keys[string(c)] = true
}
for i := 1; i <= 12; i++ {
keys[fmt.Sprintf("f%d", i)] = true
}
return keys
}
// shiftAlteredKeys are non-modifier key tokens whose printable character
// changes when Shift is physically held on a standard keyboard layout (e.g.
// "1" reports as "!", "slash" reports as "?"). The frontend dispatcher
// matches a combo against `KeyboardEvent.key`, so a stored default like
// "shift+1" or "mod+shift+slash" can never fire: Shift held down means the
// browser reports the shifted character, not the token validated here.
// Rejected at registration time so plugin authors get an actionable error
// instead of a keybinding that silently never dispatches. Letters are exempt
// because matching lowercases both sides; named/function keys are exempt
// because Shift doesn't change their `KeyboardEvent.key`. Must be kept in
// sync with the frontend's SHIFT_ALTERED_KEY_TOKENS in
// apps/web/lib/keyboard/parse-combo.ts.
var shiftAlteredKeys = buildShiftAlteredKeySet()
func buildShiftAlteredKeySet() map[string]bool {
keys := map[string]bool{
"comma": true, "period": true, "slash": true, "backslash": true,
"semicolon": true, "quote": true, "minus": true, "equal": true,
"bracketleft": true, "bracketright": true, "backquote": true,
}
for c := '0'; c <= '9'; c++ {
keys[string(c)] = true
}
return keys
}
// Validate checks the manifest against the plugin registration rules
// described in docs/specs/plugins/spec.md. It returns nil if the manifest
// is well-formed, or a joined error describing every violation found.
@@ -47,6 +128,7 @@ func (m *Manifest) Validate() error {
errs = append(errs, m.validateRepoURL()...)
errs = append(errs, m.validateUIPages()...)
errs = append(errs, m.validateUIBundle()...)
errs = append(errs, m.validateUIKeybindings()...)
errs = append(errs, m.validateWebhooks()...)
return errors.Join(errs...)
}
@@ -222,6 +304,92 @@ func (m *Manifest) validateUIBundle() []error {
return errs
}
// validateUIKeybindings checks each declared ui.keybindings entry: id is a
// non-empty slug unique within the plugin, description is non-empty, and
// default parses as a valid combo (see parseKeybindingCombo). Keybindings
// are optional, but when present they require ui.bundle — kandev dispatches
// the combo as an event the plugin's JS bundle handles, so a keybinding
// without a bundle can never do anything.
func (m *Manifest) validateUIKeybindings() []error {
var errs []error
if len(m.UI.Keybindings) > 0 && m.UI.Bundle == "" {
errs = append(errs, errors.New("ui.keybindings requires ui.bundle to be set"))
}
seen := make(map[string]bool, len(m.UI.Keybindings))
for _, kb := range m.UI.Keybindings {
errs = append(errs, validateKeybindingID(kb.ID, seen)...)
if strings.TrimSpace(kb.Description) == "" {
errs = append(errs, fmt.Errorf("ui.keybindings[%q]: description must not be empty", kb.ID))
}
if err := parseKeybindingCombo(kb.Default); err != nil {
errs = append(errs, fmt.Errorf("ui.keybindings[%q]: %w", kb.ID, err))
}
}
return errs
}
// validateKeybindingID checks a single keybinding id against the slug
// pattern and records it in seen, reporting a duplicate-id error on repeat.
func validateKeybindingID(id string, seen map[string]bool) []error {
var errs []error
if !keybindingIDPattern.MatchString(id) {
errs = append(errs, fmt.Errorf("invalid ui.keybindings id %q: must match %s", id, keybindingIDPattern.String()))
return errs
}
if seen[id] {
errs = append(errs, fmt.Errorf("duplicate ui.keybindings id %q", id))
return errs
}
seen[id] = true
return errs
}
// parseKeybindingCombo validates a keybinding combo string such as
// "mod+shift+k": tokens are split on "+", trimmed of surrounding whitespace,
// and lowercased. Every token but exactly one must be a recognized modifier
// (keybindingModifiers); the remaining single token must be a recognized key
// (keybindingKeys). It also rejects combos pairing the shift modifier with a
// key whose reported character shift would alter (see shiftAlteredKeys) since
// such a combo can never actually dispatch. It returns an error describing
// the first problem found; it does not return a normalized struct because
// validation is currently the only caller.
func parseKeybindingCombo(combo string) error {
if strings.TrimSpace(combo) == "" {
return errors.New("default combo must not be empty")
}
rawTokens := strings.Split(combo, "+")
var keyToken string
keyCount := 0
hasShift := false
for _, raw := range rawTokens {
token := strings.ToLower(strings.TrimSpace(raw))
if token == "" {
return fmt.Errorf("default combo %q has an empty token", combo)
}
if keybindingModifiers[token] {
if token == "shift" {
hasShift = true
}
continue
}
if !keybindingKeys[token] {
return fmt.Errorf("default combo %q: unknown token %q (not a recognized modifier or key)", combo, token)
}
keyToken = token
keyCount++
}
if keyCount == 0 {
return fmt.Errorf("default combo %q must include exactly one non-modifier key", combo)
}
if keyCount > 1 {
return fmt.Errorf("default combo %q must include exactly one non-modifier key, found more than one (last: %q)", combo, keyToken)
}
if hasShift && shiftAlteredKeys[keyToken] {
return fmt.Errorf("default combo %q: shift+%q is not supported because Shift changes the character reported for this key; remove shift or bind a different key", combo, keyToken)
}
return nil
}
// validateWebhooks checks for duplicate webhook keys.
func (m *Manifest) validateWebhooks() []error {
seen := make(map[string]bool, len(m.Webhooks))
@@ -356,6 +356,18 @@ export function CommandPanelView({
filter={scoreCommandSearch}
shouldFilter={mode === MODE_COMMANDS}
loop
// cmdk's built-in vim bindings intercept Ctrl/Cmd+K, +P, +J, +N as
// list-navigation shortcuts and call `event.preventDefault()` on the
// React (bubble) dispatch, which runs before this event reaches our
// `window`-level `useKeyboardShortcut` listeners. That collided with
// the app's own COMMAND_PANEL-open (Ctrl/Cmd+K) and COMMAND_PANEL
// (Ctrl/Cmd+P) shortcuts: once `useKeyboardShortcut` started bailing
// on `event.defaultPrevented` (see that hook's core-vs-plugin
// precedence guard), cmdk's vim-binding preventDefault silently
// suppressed the panel's own close/reopen toggle. Disable vim
// bindings here so this palette's Ctrl/Cmd+K and +P always reach the
// app-level toggle instead of cmdk's internal navigation.
vimBindings={false}
value={selectedValue}
onValueChange={setSelectedValue}
>
+5
View File
@@ -20,6 +20,7 @@ import {
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { useAppShortcuts } from "@/hooks/use-app-shortcuts";
import { usePluginShortcuts } from "@/hooks/use-plugin-shortcuts";
import { useAppStore } from "@/components/state-provider";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
@@ -186,7 +187,11 @@ export function GlobalCommands() {
useRegisterCommands(commands);
useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat);
// Order matters: useAppShortcuts (core) must register its capture-phase
// keydown listener before usePluginShortcuts so core shortcuts win when a
// combo matches both — see the precedence note on each hook.
useAppShortcuts();
usePluginShortcuts();
return null;
}
@@ -0,0 +1,47 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { pluginModalManager } from "@/lib/plugins/modal-manager";
import { PluginModalHost } from "./plugin-modal-host";
function cleanupModals(pluginId: string) {
pluginModalManager.closeAllForPlugin(pluginId);
}
describe("PluginModalHost", () => {
afterEach(() => {
cleanup();
cleanupModals("plugin-a");
});
it("renders nothing when no plugin has an open modal", () => {
const { container } = render(<PluginModalHost />);
expect(container.innerHTML).toBe("");
});
it("renders an open modal's title and content", () => {
pluginModalManager.openModal("plugin-a", {
title: "My Modal",
content: () => <div data-testid="modal-content">Hello</div>,
});
render(<PluginModalHost />);
expect(screen.getByText("My Modal")).not.toBeNull();
expect(screen.getByTestId("modal-content")).not.toBeNull();
});
it("removes the modal from the DOM once its handle is closed", () => {
const handle = pluginModalManager.openModal("plugin-a", {
content: () => <div data-testid="modal-content">Hello</div>,
});
render(<PluginModalHost />);
expect(screen.getByTestId("modal-content")).not.toBeNull();
act(() => {
handle.close();
});
expect(screen.queryByTestId("modal-content")).toBeNull();
});
});
@@ -0,0 +1,73 @@
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import {
pluginModalManager,
usePluginModals,
type OpenPluginModal,
} from "@/lib/plugins/modal-manager";
import type { PluginModalOptions } from "@/lib/plugins/types";
import { PluginErrorBoundary } from "./plugin-error-boundary";
/** Maps `PluginModalOptions.size` to the host's Dialog width classes. */
const SIZE_CLASSES: Record<NonNullable<PluginModalOptions["size"]>, string> = {
sm: "sm:max-w-sm",
md: "sm:max-w-xl",
lg: "sm:max-w-3xl",
xl: "sm:max-w-5xl",
};
function preventWhenNotDismissible(dismissible: boolean) {
return (event: Event) => {
if (!dismissible) event.preventDefault();
};
}
function PluginModalInstance({ modal }: { modal: OpenPluginModal }) {
const { instanceId, pluginId, options } = modal;
const dismissible = options.dismissible ?? true;
const Content = options.content;
const guardClose = preventWhenNotDismissible(dismissible);
const handleOpenChange = (open: boolean) => {
if (open || !dismissible) return;
pluginModalManager.close(instanceId);
};
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
className={SIZE_CLASSES[options.size ?? "md"]}
showCloseButton={dismissible}
onEscapeKeyDown={guardClose}
onInteractOutside={guardClose}
>
{options.title && (
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
</DialogHeader>
)}
<PluginErrorBoundary context={`modal "${instanceId}" (plugin "${pluginId}")`}>
<Content />
</PluginErrorBoundary>
</DialogContent>
</Dialog>
);
}
/**
* Renders every open plugin modal (`host.openModal(...)`) in a `@kandev/ui`
* `Dialog`, each isolated behind its own `PluginErrorBoundary`. Mounted once
* at the app root, alongside `<PluginBootBridge/>`.
*/
export function PluginModalHost() {
const modals = usePluginModals();
if (modals.length === 0) return null;
return (
<>
{modals.map((modal) => (
<PluginModalInstance key={modal.instanceId} modal={modal} />
))}
</>
);
}
@@ -28,6 +28,8 @@ import { ArchiveConfirmationSettings } from "@/components/settings/archive-confi
import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import { buildPluginShortcutEntries } from "@/lib/keyboard/plugin-shortcuts";
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
function ThemeSettingsCard({
theme,
@@ -300,6 +302,8 @@ export function KeyboardShortcutsSettings() {
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const storeApi = useAppStoreApi();
const { items: pluginItems } = usePlugins();
const pluginShortcutEntries = buildPluginShortcutEntries(pluginItems);
const [saved, setSaved] = useState(() => ({
chatSubmitKey: userSettings.chatSubmitKey,
keyboardShortcuts: userSettings.keyboardShortcuts as StoredShortcutOverrides,
@@ -353,6 +357,7 @@ export function KeyboardShortcutsSettings() {
onChange={(keyboardShortcuts) =>
setDraft((current) => ({ ...current, keyboardShortcuts }))
}
pluginEntries={pluginShortcutEntries}
/>
</SettingsSection>
</div>
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconRotate, IconX } from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconAlertTriangle, IconRotate, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Kbd } from "@kandev/ui/kbd";
import type { Key, KeyboardShortcut } from "@/lib/keyboard/constants";
import { formatShortcut } from "@/lib/keyboard/utils";
import { formatShortcut, isMac } from "@/lib/keyboard/utils";
import {
CONFIGURABLE_SHORTCUTS,
UNBOUND_SHORTCUT,
@@ -15,29 +15,43 @@ import {
type ConfigurableShortcutId,
type StoredShortcutOverrides,
} from "@/lib/keyboard/shortcut-overrides";
import {
coreShortcutEntries,
resolveShortcutEntry,
type ShortcutEntry,
} from "@/lib/keyboard/plugin-shortcuts";
import {
findShortcutConflicts,
type ShortcutConflictGroup,
} from "@/lib/keyboard/shortcut-conflicts";
import { SettingsCard } from "./settings-card";
type ShortcutRecorderProps = {
shortcutId: ConfigurableShortcutId;
shortcutId: string;
label: string;
defaultShortcut: KeyboardShortcut;
current: KeyboardShortcut;
onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void;
onReset: (id: ConfigurableShortcutId) => void;
onChange: (id: string, shortcut: KeyboardShortcut) => void;
onReset: (id: string) => void;
// Optional: callers that don't support an explicit "unbind" (e.g. the voice
// settings recorder) omit this, and the Clear button is hidden for them.
onClear?: (id: ConfigurableShortcutId) => void;
onClear?: (id: string) => void;
isDirty?: boolean;
conflictsWith?: string[];
};
export function ShortcutRecorder({
shortcutId,
label,
defaultShortcut,
current,
onChange,
onReset,
onClear,
isDirty = false,
conflictsWith,
}: ShortcutRecorderProps) {
const [recording, setRecording] = useState(false);
const defaultShortcut = CONFIGURABLE_SHORTCUTS[shortcutId].default;
const isDefault = JSON.stringify(current) === JSON.stringify(defaultShortcut);
const isUnbound = isUnboundShortcut(current);
const defaultIsUnbound = isUnboundShortcut(defaultShortcut);
@@ -84,7 +98,7 @@ export function ShortcutRecorder({
return (
<div className="flex items-center justify-between py-2">
<span className="text-sm">{CONFIGURABLE_SHORTCUTS[shortcutId].label}</span>
<ShortcutRecorderLabel label={label} conflictsWith={conflictsWith} />
<div className="flex items-center gap-2">
<button
data-testid={`shortcut-recorder-${shortcutId}`}
@@ -98,35 +112,86 @@ export function ShortcutRecorder({
>
{renderRecorderLabel({ recording, current, isUnbound })}
</button>
{onClear && !isUnbound && !defaultIsUnbound && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onClear(shortcutId)}
aria-label="Clear shortcut"
title="Clear shortcut"
>
<IconX className="size-3.5" />
</Button>
)}
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onReset(shortcutId)}
aria-label={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
title={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
>
<IconRotate className="size-3.5" />
</Button>
)}
<ShortcutRecorderActions
shortcutId={shortcutId}
isDefault={isDefault}
isUnbound={isUnbound}
defaultIsUnbound={defaultIsUnbound}
onReset={onReset}
onClear={onClear}
/>
</div>
</div>
);
}
function ShortcutRecorderLabel({
label,
conflictsWith,
}: {
label: string;
conflictsWith?: string[];
}) {
return (
<span className="text-sm flex items-center gap-1.5">
{label}
{conflictsWith && conflictsWith.length > 0 && (
<span
className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500"
title={`Same shortcut as: ${conflictsWith.join(", ")}`}
>
<IconAlertTriangle className="size-3.5" />
</span>
)}
</span>
);
}
function ShortcutRecorderActions({
shortcutId,
isDefault,
isUnbound,
defaultIsUnbound,
onReset,
onClear,
}: {
shortcutId: string;
isDefault: boolean;
isUnbound: boolean;
defaultIsUnbound: boolean;
onReset: (id: string) => void;
onClear?: (id: string) => void;
}) {
return (
<>
{onClear && !isUnbound && !defaultIsUnbound && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onClear(shortcutId)}
aria-label="Clear shortcut"
title="Clear shortcut"
>
<IconX className="size-3.5" />
</Button>
)}
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onReset(shortcutId)}
aria-label={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
title={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
>
<IconRotate className="size-3.5" />
</Button>
)}
</>
);
}
function renderRecorderLabel({
recording,
current,
@@ -141,27 +206,54 @@ function renderRecorderLabel({
return <Kbd>{formatShortcut(current)}</Kbd>;
}
/** Builds a `shortcutId -> conflicting labels` lookup from conflict groups. */
function buildConflictLabels(groups: ShortcutConflictGroup[]): Map<string, string[]> {
const labels = new Map<string, string[]>();
for (const group of groups) {
for (const entry of group.entries) {
labels.set(
entry.id,
group.entries.filter((other) => other.id !== entry.id).map((other) => other.label),
);
}
}
return labels;
}
export function KeyboardShortcutsCard({
overrides,
baselineOverrides = {},
onChange,
pluginEntries = [],
}: {
overrides: StoredShortcutOverrides;
baselineOverrides?: StoredShortcutOverrides;
onChange: (overrides: StoredShortcutOverrides) => void;
/** Dynamic plugin-declared shortcuts (see `lib/keyboard/plugin-shortcuts.ts`). */
pluginEntries?: ShortcutEntry[];
}) {
const shortcuts = resolveAllShortcuts(overrides);
const baselineShortcuts = resolveAllShortcuts(baselineOverrides);
const allEntries = useMemo(() => [...coreShortcutEntries(), ...pluginEntries], [pluginEntries]);
const conflictLabels = useMemo(() => {
const resolved = allEntries.map((entry) => ({
entry,
shortcut: resolveShortcutEntry(entry, overrides),
}));
return buildConflictLabels(findShortcutConflicts(resolved, isMac()));
}, [allEntries, overrides]);
const handleChange = useCallback(
(id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => {
(id: string, shortcut: KeyboardShortcut) => {
onChange({ ...overrides, [id]: shortcut });
},
[onChange, overrides],
);
const handleReset = useCallback(
(id: ConfigurableShortcutId) => {
(id: string) => {
const next = { ...overrides };
delete next[id];
onChange(next);
@@ -170,7 +262,7 @@ export function KeyboardShortcutsCard({
);
const handleClear = useCallback(
(id: ConfigurableShortcutId) => {
(id: string) => {
onChange({ ...overrides, [id]: UNBOUND_SHORTCUT });
},
[onChange, overrides],
@@ -189,14 +281,41 @@ export function KeyboardShortcutsCard({
<ShortcutRecorder
key={id}
shortcutId={id}
label={CONFIGURABLE_SHORTCUTS[id].label}
defaultShortcut={CONFIGURABLE_SHORTCUTS[id].default}
current={shortcuts[id]}
onChange={handleChange}
onReset={handleReset}
onClear={handleClear}
isDirty={JSON.stringify(shortcuts[id]) !== JSON.stringify(baselineShortcuts[id])}
conflictsWith={conflictLabels.get(id)}
/>
))}
</div>
{pluginEntries.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-muted-foreground mb-1">Plugin Shortcuts</p>
<div className="divide-y divide-border">
{pluginEntries.map((entry) => (
<ShortcutRecorder
key={entry.id}
shortcutId={entry.id}
label={entry.label}
defaultShortcut={entry.default}
current={resolveShortcutEntry(entry, overrides)}
onChange={handleChange}
onReset={handleReset}
onClear={handleClear}
isDirty={
JSON.stringify(resolveShortcutEntry(entry, overrides)) !==
JSON.stringify(resolveShortcutEntry(entry, baselineOverrides))
}
conflictsWith={conflictLabels.get(entry.id)}
/>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground mt-3">
Click a shortcut to record a new key combination.
</p>
@@ -525,6 +525,8 @@ function VoiceShortcutCard() {
<CardContent>
<ShortcutRecorder
shortcutId="VOICE_INPUT_TOGGLE"
label={CONFIGURABLE_SHORTCUTS.VOICE_INPUT_TOGGLE.label}
defaultShortcut={CONFIGURABLE_SHORTCUTS.VOICE_INPUT_TOGGLE.default}
current={current}
onChange={handleChange}
onReset={handleReset}
@@ -267,6 +267,39 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect.poll(() => fs.existsSync(pluginDir), { timeout: 10_000 }).toBe(false);
});
test("keybinding declared in the manifest opens a host.openModal demo modal", async ({
testPage,
}) => {
test.setTimeout(60_000);
// --- Install via the upload UI, then reload so the boot payload (and
// the manifest-driven keybinding registration) is live. ---
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await expect(pluginRow).toBeVisible({ timeout: 15_000 });
await testPage.goto("/");
await testPage.reload();
await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible({
timeout: 15_000,
});
// --- manifest.yaml declares `ui.keybindings: [{ id: open-demo, default:
// mod+shift+j }]`; bundle.js binds it to host.openModal(...). "mod"
// resolves to Ctrl/Cmd per-platform, matching Playwright's
// "ControlOrMeta" pseudo-modifier. ---
await testPage.keyboard.press("ControlOrMeta+Shift+J");
const modal = testPage.getByTestId("hello-demo-modal");
await expect(modal).toBeVisible();
await expect(modal).toHaveText("Hello from the plugin modal");
// --- The host Dialog's built-in close button dismisses the (dismissible
// by default) modal. ---
await testPage.getByRole("button", { name: "Close" }).click();
await expect(modal).not.toBeVisible();
});
test("settings page: schema-driven form, secret masking, and Host GetConfig delivery", async ({
testPage,
apiClient,
+16 -10
View File
@@ -2,17 +2,9 @@
import { useEffect } from "react";
import { useAppStoreApi } from "@/components/state-provider";
import { matchesShortcut } from "@/lib/keyboard/utils";
import { isEditableKeydownTarget, matchesShortcut } from "@/lib/keyboard/utils";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
/** Returns true if the active element is a text input or contenteditable. */
function isEditableTarget(e: KeyboardEvent): boolean {
const tag = (e.target as HTMLElement)?.tagName;
return (
tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable === true
);
}
/**
* App-root keyboard shortcuts that must fire on every route — not just inside
* the dockview session editor.
@@ -25,13 +17,27 @@ function isEditableTarget(e: KeyboardEvent): boolean {
*
* The AppSidebar is hidden below the `md` breakpoint; on mobile the toggle still
* flips store state but has no visible effect, which is fine.
*
* Core-vs-plugin precedence: must be mounted (in `components/global-commands.tsx`)
* before `usePluginShortcuts()` so this capture-phase listener registers — and
* therefore runs — first. `usePluginShortcuts` bails out on
* `event.defaultPrevented`, so a combo bound to both a core shortcut here and a
* plugin keybinding fires only the core action.
*
* Full precedence chain (a matching combo fires exactly one action):
* central `useAppShortcuts` (this hook, capture phase, mounted first) wins
* over plugin keybindings (`usePluginShortcuts`, capture phase, mounted
* second), which win over per-component core shortcuts registered via
* `useKeyboardShortcut` (bubble phase) — that hook also bails out on
* `event.defaultPrevented`.
*/
export function useAppShortcuts() {
const appStore = useAppStoreApi();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isEditableTarget(e)) return;
if (e.defaultPrevented) return;
if (isEditableKeydownTarget(e)) return;
const overrides = appStore.getState().userSettings.keyboardShortcuts;
if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) {
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import type { DockviewApi } from "dockview-react";
const mockToggleAppSidebar = vi.fn();
const mockToggleBottomTerminal = vi.fn();
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({
getState: () => ({
userSettings: { keyboardShortcuts: {} },
bottomTerminal: { isOpen: false },
toggleAppSidebar: mockToggleAppSidebar,
toggleBottomTerminal: mockToggleBottomTerminal,
tasks: { activeTaskId: null, activeSessionId: null },
environmentIdBySessionId: {},
}),
}),
}));
import { useEditorKeybinds } from "./use-editor-keybinds";
import { useDockviewStore } from "@/lib/state/dockview-store";
function pressKey(
key: string,
init: KeyboardEventInit = {},
target: EventTarget = window,
): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
/**
* Minimal fake `DockviewApi` exercising only the tab-navigation branch of
* `useEditorKeybinds`: two panels in the active panel's group, so
* `handleTabNavigation` can find the "next" panel and call its
* `api.setActive()`.
*/
function makeFakeApi(activateSpy: (id: string) => void): DockviewApi {
type FakePanel = { id: string; api: { setActive: () => void }; group?: { panels: FakePanel[] } };
const panelA: FakePanel = { id: "panel-a", api: { setActive: () => activateSpy("panel-a") } };
const panelB: FakePanel = { id: "panel-b", api: { setActive: () => activateSpy("panel-b") } };
const group = { panels: [panelA, panelB] };
panelA.group = group;
panelB.group = group;
return {
activePanel: panelA,
} as unknown as DockviewApi;
}
describe("useEditorKeybinds", () => {
afterEach(() => {
cleanup();
useDockviewStore.setState({ api: null });
mockToggleAppSidebar.mockClear();
mockToggleBottomTerminal.mockClear();
});
it("does not run the editor action (tab navigation) when the event is already defaultPrevented", () => {
// Regression: `useEditorKeybinds`'s capture-phase handler guards on
// `event.defaultPrevented` so a combo already handled by another
// capture-phase core dispatcher or a plugin keybinding doesn't also run
// the editor's tab-navigation/terminal-toggle logic (double action for a
// single keypress). This exercises that guard directly, at the hook
// level, rather than only through `useKeyboardShortcut`.
const activateSpy = vi.fn();
useDockviewStore.setState({ api: makeFakeApi(activateSpy) });
// Register this capture-phase listener BEFORE mounting the hook so it
// runs first (capture-phase listeners on the same target fire in
// registration order) — mirroring a real prior capture dispatcher (a
// core shortcut or plugin keybinding) that already handled the event.
const preventDefaultListener = (event: KeyboardEvent) => {
event.preventDefault();
};
window.addEventListener("keydown", preventDefaultListener, { capture: true });
renderHook(() => useEditorKeybinds());
try {
pressKey("]", { ctrlKey: true, shiftKey: true, code: "BracketRight" });
expect(activateSpy).not.toHaveBeenCalled();
} finally {
window.removeEventListener("keydown", preventDefaultListener, { capture: true });
}
});
it("runs the editor action (tab navigation) for a non-prevented matching event", () => {
const activateSpy = vi.fn();
useDockviewStore.setState({ api: makeFakeApi(activateSpy) });
renderHook(() => useEditorKeybinds());
pressKey("]", { ctrlKey: true, shiftKey: true, code: "BracketRight" });
expect(activateSpy).toHaveBeenCalledWith("panel-b");
});
});
+6
View File
@@ -136,6 +136,12 @@ export function useEditorKeybinds() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Another capture-phase core dispatcher (`useAppShortcuts`) or a
// plugin keybinding (`usePluginShortcuts`) may have already handled
// and prevented this event — bail out so a combo shared with tab
// navigation, terminal toggle, or BOTTOM_TERMINAL fires only once.
if (e.defaultPrevented) return;
const api = useDockviewStore.getState().api;
if (!api) return;
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import { useKeyboardShortcut } from "./use-keyboard-shortcut";
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
const SHORTCUT: KeyboardShortcut = { key: "k", modifiers: { ctrlOrCmd: true, shift: true } };
function pressKey(
key: string,
init: KeyboardEventInit = {},
target: EventTarget = window,
): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
describe("useKeyboardShortcut", () => {
afterEach(() => cleanup());
it("invokes the callback when the combo matches", () => {
const callback = vi.fn();
renderHook(() => useKeyboardShortcut(SHORTCUT, callback));
pressKey("k", { ctrlKey: true, shiftKey: true });
expect(callback).toHaveBeenCalledTimes(1);
});
it("does not invoke the callback when the event was already handled (defaultPrevented)", () => {
// Regression: a bubble-phase useKeyboardShortcut listener must not act on
// an event a capture-phase dispatcher (e.g. a plugin keybinding handler)
// already handled and called preventDefault() on — otherwise the same
// combo fires two actions instead of one.
const callback = vi.fn();
renderHook(() => useKeyboardShortcut(SHORTCUT, callback));
const preventDefaultListener = (event: KeyboardEvent) => {
event.preventDefault();
};
window.addEventListener("keydown", preventDefaultListener, { capture: true });
try {
pressKey("k", { ctrlKey: true, shiftKey: true });
expect(callback).not.toHaveBeenCalled();
} finally {
window.removeEventListener("keydown", preventDefaultListener, { capture: true });
}
});
});
+7
View File
@@ -47,6 +47,13 @@ export function useKeyboardShortcut(
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
// Precedence: a capture-phase core dispatcher (`useAppShortcuts`) or a
// plugin keybinding (`usePluginShortcuts`) may have already handled and
// prevented this event before this bubble-phase, per-component listener
// runs. Bail out so the same combo fires exactly one action instead of
// both. See `useAppShortcuts`/`usePluginShortcuts` for the full
// core-vs-plugin precedence chain.
if (event.defaultPrevented) return;
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
+388
View File
@@ -0,0 +1,388 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import type { PluginRecord } from "@/lib/types/plugins";
import { pluginRegistry } from "@/lib/plugins/registry";
let mockOverrides: StoredShortcutOverrides = {};
let mockItems: PluginRecord[] = [];
const mockToggleAppSidebar = vi.fn();
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({
getState: () => ({
userSettings: { keyboardShortcuts: mockOverrides },
toggleAppSidebar: mockToggleAppSidebar,
}),
}),
}));
vi.mock("@/hooks/domains/plugins/use-plugins", () => ({
usePlugins: () => ({ items: mockItems, loaded: true, loading: false, error: null }),
}));
import { usePluginShortcuts } from "./use-plugin-shortcuts";
import { useAppShortcuts } from "./use-app-shortcuts";
import { useKeyboardShortcut } from "./use-keyboard-shortcut";
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
const PLUGIN_ID = "session-cost";
function makePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
return {
id: PLUGIN_ID,
api_version: 1,
version: "1.0.0",
display_name: "Session Cost",
description: "",
author: "",
categories: [],
capabilities: {},
status: "active",
install_path: "",
signed: true,
installed_at: "",
restart_count: 0,
...overrides,
};
}
function pressKey(
key: string,
init: KeyboardEventInit = {},
target: EventTarget = window,
): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
const TOGGLE_KEYBINDING = { id: "toggle", default: "mod+shift+e", description: "Toggle" };
/** A free combo (not used by any core shortcut) shared by two plugins bound to the same "open" id. */
const OPEN_COMBO = "mod+shift+o";
function withToggleKeybinding(overrides: Partial<PluginRecord> = {}): PluginRecord {
return makePlugin({ ui: { keybindings: [TOGGLE_KEYBINDING] }, ...overrides });
}
describe("usePluginShortcuts", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
pluginRegistry.unregisterPlugin("plugin-a");
pluginRegistry.unregisterPlugin("plugin-b");
});
it("invokes the bound handler when the manifest default combo is pressed", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
});
it("does not invoke the handler when no key matches", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
pressKey("j", { ctrlKey: true });
expect(handler).not.toHaveBeenCalled();
});
it("skips dispatch while typing in an editable element", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("e", { ctrlKey: true, shiftKey: true }, input);
input.remove();
expect(handler).not.toHaveBeenCalled();
});
it("does nothing when the plugin bundle has not registered a handler yet", () => {
mockItems = [withToggleKeybinding()];
renderHook(() => usePluginShortcuts());
expect(() => pressKey("e", { ctrlKey: true, shiftKey: true })).not.toThrow();
});
it("respects a user override combo instead of the manifest default", () => {
mockItems = [withToggleKeybinding()];
mockOverrides = {
[`plugin:${PLUGIN_ID}:toggle`]: { key: "u", modifiers: { ctrlOrCmd: true } },
};
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
pressKey("e", { ctrlKey: true, shiftKey: true }); // manifest default — no longer bound
expect(handler).not.toHaveBeenCalled();
pressKey("u", { ctrlKey: true }); // user override — now bound
expect(handler).toHaveBeenCalledTimes(1);
});
it("dispatches to every plugin bound to the same combo, in registration order", () => {
mockItems = [
makePlugin({
id: "plugin-a",
display_name: "Plugin A",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
makePlugin({
id: "plugin-b",
display_name: "Plugin B",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
];
const callOrder: string[] = [];
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => callOrder.push("a"));
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", () => callOrder.push("b"));
renderHook(() => usePluginShortcuts());
pressKey("o", { ctrlKey: true, shiftKey: true });
expect(callOrder).toEqual(["a", "b"]);
});
it("removes its listener on unmount", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
const { unmount } = renderHook(() => usePluginShortcuts());
unmount();
pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).not.toHaveBeenCalled();
});
});
describe("usePluginShortcuts handler exceptions", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin("plugin-a");
pluginRegistry.unregisterPlugin("plugin-b");
});
it("logs and continues past a throwing handler so later handlers still run", () => {
mockItems = [
makePlugin({
id: "plugin-a",
display_name: "Plugin A",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
makePlugin({
id: "plugin-b",
display_name: "Plugin B",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
];
const secondHandler = vi.fn();
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => {
throw new Error("boom");
});
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", secondHandler);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
renderHook(() => usePluginShortcuts());
expect(() => pressKey("o", { ctrlKey: true, shiftKey: true })).not.toThrow();
expect(secondHandler).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("plugin-a"),
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
});
describe("core vs plugin shortcut precedence", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("fires only the core action when a combo matches both a core shortcut and a plugin keybinding", () => {
mockOverrides = { TOGGLE_SIDEBAR: { key: "b", modifiers: { ctrlOrCmd: true } } };
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "open", default: "mod+b", description: "Open" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
renderHook(() => {
useAppShortcuts();
usePluginShortcuts();
});
const event = pressKey("b", { ctrlKey: true });
expect(mockToggleAppSidebar).toHaveBeenCalledTimes(1);
expect(pluginHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
it("still fires the plugin handler for a plugin-only combo (no core shortcut match)", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => {
useAppShortcuts();
usePluginShortcuts();
});
pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(mockToggleAppSidebar).not.toHaveBeenCalled();
});
it("fires only the plugin action when a combo matches both a plugin keybinding and a per-component core shortcut bound via useKeyboardShortcut", () => {
// Regression for Greptile P1 (PR #1895, comment 3638620657): a
// per-component core shortcut registered through `useKeyboardShortcut`
// runs in the bubble phase, after this capture-phase plugin dispatcher.
// Without `useKeyboardShortcut` respecting `event.defaultPrevented`, the
// plugin handler AND the core action would both fire for the same combo.
mockItems = [withToggleKeybinding()];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", pluginHandler);
const coreShortcut: KeyboardShortcut = {
key: "e",
modifiers: { ctrlOrCmd: true, shift: true },
};
const coreCallback = vi.fn();
renderHook(() => {
// usePluginShortcuts registers its capture-phase listener on mount,
// which — like the real app (`components/global-commands.tsx`) —
// fires before the bubble-phase useKeyboardShortcut listener below.
usePluginShortcuts();
useKeyboardShortcut(coreShortcut, coreCallback);
});
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
expect(pluginHandler).toHaveBeenCalledTimes(1);
expect(coreCallback).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
describe("core shortcut combo shadowing (robust, non-registry-aware core shortcuts)", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("does not invoke or preventDefault a plugin bound to FIND_IN_PANEL's combo, leaving core panel-search to handle it", () => {
// Regression for Greptile P1 (PR #1895, comment 3638902528): FIND_IN_PANEL
// (Cmd/Ctrl+F) is handled by independent per-panel capture-phase
// listeners (`use-panel-search.ts`) that never call `preventDefault` up
// front and don't check it either. The old "bail on defaultPrevented"
// guard couldn't protect this combo at all — only skipping the plugin
// handler outright (by combo, not by event state) fixes it.
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "find", default: "mod+f", description: "Find" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("find", pluginHandler);
renderHook(() => usePluginShortcuts());
const event = pressKey("f", { ctrlKey: true });
expect(pluginHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it("still fires a plugin bound to a genuinely free combo (no core shortcut uses it)", () => {
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "open", default: "mod+shift+u", description: "Open" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
renderHook(() => usePluginShortcuts());
const event = pressKey("u", { ctrlKey: true, shiftKey: true });
expect(pluginHandler).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
});
it("shadows the plugin by a user's REMAPPED core combo, not the old default", () => {
// A user remaps TASK_SWITCHER (default Cmd/Ctrl+Space) to Cmd/Ctrl+U. A
// plugin bound to the OLD default (mod+space) should now fire freely,
// and a plugin bound to the NEW combo (mod+u) should be shadowed.
mockOverrides = { TASK_SWITCHER: { key: "u", modifiers: { ctrlOrCmd: true } } };
mockItems = [
makePlugin({
id: "plugin-old-combo",
display_name: "Old Combo Plugin",
ui: { keybindings: [{ id: "open", default: "mod+space", description: "Open" }] },
}),
makePlugin({
id: "plugin-new-combo",
display_name: "New Combo Plugin",
ui: { keybindings: [{ id: "open", default: "mod+u", description: "Open" }] },
}),
];
const oldComboHandler = vi.fn();
const newComboHandler = vi.fn();
pluginRegistry.forPlugin("plugin-old-combo").registerKeybinding("open", oldComboHandler);
pluginRegistry.forPlugin("plugin-new-combo").registerKeybinding("open", newComboHandler);
renderHook(() => usePluginShortcuts());
pressKey(" ", { ctrlKey: true }); // old TASK_SWITCHER default — no longer core, plugin fires
expect(oldComboHandler).toHaveBeenCalledTimes(1);
const event = pressKey("u", { ctrlKey: true }); // new TASK_SWITCHER combo — shadows the plugin
expect(newComboHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
pluginRegistry.unregisterPlugin("plugin-old-combo");
pluginRegistry.unregisterPlugin("plugin-new-combo");
});
});
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useEffect } from "react";
import { useAppStoreApi } from "@/components/state-provider";
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
import { isEditableKeydownTarget, isMac, matchesShortcut } from "@/lib/keyboard/utils";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import {
buildConfigurableShortcutEntries,
coreShortcutEntries,
resolveShortcutEntry,
} from "@/lib/keyboard/plugin-shortcuts";
import { comboKey } from "@/lib/keyboard/shortcut-conflicts";
import { SHORTCUTS } from "@/lib/keyboard/constants";
/**
* Central shortcuts that are not in `CONFIGURABLE_SHORTCUTS` (so they have no
* user override to resolve) but are still global, always-on core behavior
* that must win over a plugin keybinding bound to the same combo:
* - `FIND_IN_PANEL` (Cmd/Ctrl+F) — per-panel capture-phase listeners
* (`use-panel-search.ts`, terminal find) that don't check
* `event.defaultPrevented`.
* - `SAVE` (Cmd/Ctrl+S) — reserved for editor save; must not be hijacked by a
* plugin even before a save listener exists for every surface.
*
* Other `SHORTCUTS` entries not in this list (`SUBMIT`, `SUBMIT_ENTER`,
* `CANCEL`, `COMMAND_PANEL_SHIFT`) are contextual/per-component shortcuts —
* typically registered via `useKeyboardShortcut`, which already yields to
* plugin keybindings — so they're intentionally excluded here.
*/
const NON_CONFIGURABLE_CORE_SHORTCUT_IDS = [
"FIND_IN_PANEL",
"SAVE",
] as const satisfies ReadonlyArray<keyof typeof SHORTCUTS>;
/**
* Global dispatcher for plugin-declared keybindings (`ui.keybindings`),
* mirroring `useAppShortcuts`'s capture-phase, editable-skip pattern. Mount
* once at the app root (alongside `useAppShortcuts`, see
* `components/global-commands.tsx`).
*
* Also syncs each active plugin's declared keybinding ids into
* `pluginRegistry` (`setDeclaredKeybindingIds`) so `registerKeybinding` can
* warn on an id the manifest never declared — this is the one call site with
* both the plugin records (`usePlugins`) and the registry.
*
* Dispatch order when two active plugins bind the same effective combo:
* registration order (`pluginRegistry.getKeybindingHandlers()` iteration
* order) — the first plugin to call `registerKeybinding` for that combo
* wins; later handlers for the same event still run since this dispatches
* to every match, not just the first.
*
* Core-vs-plugin precedence: effective CORE shortcuts always win over plugin
* keybindings. There is no single core keydown dispatcher — panel-search,
* task-switcher, save, editor keybinds, and other per-component listeners
* are all independent, and most of them don't (and shouldn't have to) check
* `event.defaultPrevented`. So instead of relying on every core listener
* cooperating, this hook computes the set of effective CORE shortcut combos
* (the central `SHORTCUTS`/`CONFIGURABLE_SHORTCUTS` registry, resolved
* through any user overrides — see `buildCoreComboKeySet` below) and simply
* never invokes a plugin handler whose effective combo is in that set. The
* plugin handler is skipped entirely — no invocation, no `preventDefault()`
* — so whichever core listener owns that combo runs normally, exactly once.
* This makes panel-search/task-switcher/save/find-in-panel/etc. win over
* plugins without editing any of those listeners, and it still works when a
* user remaps a core shortcut's combo (the override is read fresh on every
* keydown).
*
* Among non-core listeners, this capture-phase plugin dispatcher wins over
* per-component core shortcuts registered via `useKeyboardShortcut` (bubble
* phase) and `useEditorKeybinds`'s capture-phase handler: this hook calls
* `event.preventDefault()` whenever it invokes a plugin handler for a
* non-core combo, and those listeners bail out when `event.defaultPrevented`
* is already true. So the full chain — effective core shortcuts (always win)
* > plugin keybindings > per-component core shortcuts — resolves to exactly
* one action per combo.
*/
export function usePluginShortcuts(): void {
const appStore = useAppStoreApi();
const { items } = usePlugins();
usePluginRegistry();
useEffect(() => {
for (const plugin of items) {
pluginRegistry.setDeclaredKeybindingIds(
plugin.id,
(plugin.ui?.keybindings ?? []).map((kb) => kb.id),
);
}
}, [items]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
// A core dispatcher already handled this combo (and called
// preventDefault) — core wins, so bail out without invoking any
// plugin handler for the same keypress.
if (event.defaultPrevented) return;
if (isEditableKeydownTarget(event)) return;
const overrides = appStore.getState().userSettings
.keyboardShortcuts as StoredShortcutOverrides;
dispatchMatchingPluginShortcuts(event, items, overrides);
};
// Capture phase so plugin shortcuts win before focus-trapped surfaces
// (e.g. xterm.js) can swallow the event — mirrors useAppShortcuts.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [appStore, items]);
}
/**
* Builds the set of effective CORE shortcut combo-keys (registry defaults,
* resolved through any user override), so the dispatcher can recognize a
* plugin combo that shadows a core one. Recomputed on every keydown from the
* `overrides` snapshot read in the handler, so a user remapping a core
* shortcut's combo takes effect immediately — no separate memoization
* lifecycle to keep in sync with the override store.
*/
function buildCoreComboKeySet(
overrides: StoredShortcutOverrides,
isMacPlatform: boolean,
): Set<string> {
const keys = new Set<string>();
for (const entry of coreShortcutEntries()) {
const shortcut = resolveShortcutEntry(entry, overrides);
const key = comboKey(shortcut, isMacPlatform);
if (key !== null) keys.add(key);
}
for (const id of NON_CONFIGURABLE_CORE_SHORTCUT_IDS) {
const key = comboKey(SHORTCUTS[id], isMacPlatform);
if (key !== null) keys.add(key);
}
return keys;
}
/**
* Dispatches `event` to every registered plugin keybinding handler whose
* effective combo matches. Iterates in `pluginRegistry.getKeybindingHandlers()`
* order — registration order — so when two plugins bind the same combo, the
* plugin that called `registerKeybinding` first runs first (both still run;
* this only fixes the order, it does not stop at the first match).
*
* Before invoking a handler, checks whether that plugin combo's `comboKey`
* matches an effective CORE shortcut (see `buildCoreComboKeySet`). If it
* does, the plugin handler is skipped entirely — core always wins, and the
* corresponding core listener (panel-search, task-switcher, save, etc.)
* handles the event normally.
*
* Each handler invocation is isolated in its own try/catch: a throwing
* handler is logged (with the offending plugin/keybinding id) and does not
* abort the loop, so one broken plugin can't prevent other plugins bound to
* the same combo from running.
*/
function dispatchMatchingPluginShortcuts(
event: KeyboardEvent,
plugins: Parameters<typeof buildConfigurableShortcutEntries>[0],
overrides: StoredShortcutOverrides,
): void {
const entryById = new Map(
buildConfigurableShortcutEntries(plugins)
.filter((entry) => entry.source === "plugin")
.map((entry) => [entry.id, entry] as const),
);
const isMacPlatform = isMac();
const coreComboKeys = buildCoreComboKeySet(overrides, isMacPlatform);
for (const { pluginId, id, handler } of pluginRegistry.getKeybindingHandlers()) {
const entry = entryById.get(`plugin:${pluginId}:${id}`);
if (!entry) continue;
const shortcut = resolveShortcutEntry(entry, overrides);
if (!matchesShortcut(event, shortcut)) continue;
const pluginComboKey = comboKey(shortcut, isMacPlatform);
if (pluginComboKey !== null && coreComboKeys.has(pluginComboKey)) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`[plugin:${pluginId}] keybinding "${id}" is shadowed by a core shortcut bound to the same combo and will not fire.`,
);
}
continue;
}
event.preventDefault();
try {
handler(event);
} catch (err) {
console.error(`[plugin:${pluginId}] keybinding "${id}" handler threw an error:`, err);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { parseCombo } from "./parse-combo";
describe("parseCombo", () => {
it("parses a plain single-key combo", () => {
expect(parseCombo("k")).toEqual({ key: "k" });
});
it("parses mod+key as ctrlOrCmd (platform resolved by matchesShortcut)", () => {
expect(parseCombo("mod+k")).toEqual({ key: "k", modifiers: { ctrlOrCmd: true } });
});
it("parses multiple modifiers plus one key", () => {
expect(parseCombo("mod+shift+k")).toEqual({
key: "k",
modifiers: { ctrlOrCmd: true, shift: true },
});
});
it("treats ctrl as an explicit ctrl modifier (not ctrlOrCmd)", () => {
expect(parseCombo("ctrl+r")).toEqual({ key: "r", modifiers: { ctrl: true } });
});
it("treats cmd/meta as an explicit cmd modifier", () => {
expect(parseCombo("cmd+k")).toEqual({ key: "k", modifiers: { cmd: true } });
expect(parseCombo("meta+k")).toEqual({ key: "k", modifiers: { cmd: true } });
});
it("returns null for undocumented modifier aliases (control/super are not in the accepted grammar)", () => {
expect(parseCombo("control+r")).toBeNull();
expect(parseCombo("super+k")).toBeNull();
});
it("treats alt/option as an explicit alt modifier", () => {
expect(parseCombo("alt+k")).toEqual({ key: "k", modifiers: { alt: true } });
expect(parseCombo("option+k")).toEqual({ key: "k", modifiers: { alt: true } });
});
it("resolves named key tokens to KeyboardEvent.key equivalents", () => {
expect(parseCombo("mod+enter")).toEqual({ key: "Enter", modifiers: { ctrlOrCmd: true } });
expect(parseCombo("shift+arrowup")).toEqual({ key: "ArrowUp", modifiers: { shift: true } });
});
it("resolves a symbol key token when shift is not involved", () => {
expect(parseCombo("mod+slash")).toEqual({ key: "/", modifiers: { ctrlOrCmd: true } });
});
it("returns null when shift is combined with a key whose character shift would alter", () => {
// Physically holding Shift means the browser reports "?"/"!" for
// KeyboardEvent.key, not "/"/"1" — so these combos could never dispatch.
expect(parseCombo("shift+slash")).toBeNull();
expect(parseCombo("mod+shift+slash")).toBeNull();
expect(parseCombo("shift+1")).toBeNull();
expect(parseCombo("shift+minus")).toBeNull();
});
it("still allows shift with keys Shift doesn't alter (letters, named/function keys)", () => {
expect(parseCombo("shift+k")).toEqual({ key: "k", modifiers: { shift: true } });
expect(parseCombo("shift+tab")).toEqual({ key: "Tab", modifiers: { shift: true } });
expect(parseCombo("shift+f1")).toEqual({ key: "f1", modifiers: { shift: true } });
});
it("resolves function keys", () => {
expect(parseCombo("mod+f5")).toEqual({ key: "f5", modifiers: { ctrlOrCmd: true } });
});
it("is case-insensitive on tokens", () => {
expect(parseCombo("MOD+SHIFT+K")).toEqual({
key: "k",
modifiers: { ctrlOrCmd: true, shift: true },
});
});
it("returns null for an empty combo", () => {
expect(parseCombo("")).toBeNull();
expect(parseCombo(" ")).toBeNull();
});
it("returns null for an unknown token", () => {
expect(parseCombo("mod+banana")).toBeNull();
});
it("returns null when the combo has more than one non-modifier key", () => {
expect(parseCombo("k+j")).toBeNull();
});
it("returns null when the combo has only modifiers and no key", () => {
expect(parseCombo("mod+shift")).toBeNull();
});
it("returns null for a token with an empty segment", () => {
expect(parseCombo("mod++k")).toBeNull();
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Parses a plugin manifest keybinding combo string (e.g. "mod+shift+k") into
* the core `KeyboardShortcut` shape consumed by `matchesShortcut`.
*
* Grammar mirrors the backend validator
* (`apps/backend/internal/plugins/manifest/validate.go` `parseKeybindingCombo`),
* which already rejected malformed combos at plugin registration time:
* `+`-separated tokens, modifiers from a fixed vocabulary, and exactly one
* non-modifier key token. This parser is defensive (returns `null` instead of
* throwing) since it also runs against values that already passed backend
* validation.
*
* The accepted modifier vocabulary — `mod|ctrl|cmd|meta|alt|option|shift` —
* matches the public contract in `docs/plans/plugins/PLUGIN-API.md` exactly.
* "control"/"super" are deliberately NOT accepted (kept in lockstep with the
* backend validator, which also rejects them).
*
* `mod` maps to `ctrlOrCmd` in the `KeyboardShortcut` shape — `matchesShortcut`
* already resolves `ctrlOrCmd` to Cmd on macOS / Ctrl elsewhere, so no
* separate platform branch is needed here.
*
* Combos pairing `shift` with a key whose printable character Shift would
* alter (digits, symbols — see `SHIFT_ALTERED_KEY_TOKENS`) are rejected: the
* combo is matched against `KeyboardEvent.key`, but a physically-held Shift
* means the browser reports the shifted character ("!", "?") instead of the
* unshifted token, so the combo could never actually dispatch. This mirrors
* the backend validator's `shiftAlteredKeys` rejection.
*/
import type { Key, KeyboardShortcut } from "./constants";
type ModifierToken = "mod" | "ctrl" | "cmd" | "meta" | "alt" | "option" | "shift";
const MODIFIER_TOKENS: Record<ModifierToken, keyof NonNullable<KeyboardShortcut["modifiers"]>> = {
mod: "ctrlOrCmd",
ctrl: "ctrl",
cmd: "cmd",
meta: "cmd",
alt: "alt",
option: "alt",
shift: "shift",
};
/**
* Non-modifier key tokens whose reported `KeyboardEvent.key` changes when
* Shift is held (e.g. "1" -> "!", "slash" -> "?"). Must stay in sync with the
* backend's `shiftAlteredKeys` in
* `apps/backend/internal/plugins/manifest/validate.go`.
*/
const SHIFT_ALTERED_KEY_TOKENS = new Set<string>([
"comma",
"period",
"slash",
"backslash",
"semicolon",
"quote",
"minus",
"equal",
"bracketleft",
"bracketright",
"backquote",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
]);
/** Named-key tokens mapped to their `KeyboardEvent.key` equivalent. */
const NAMED_KEY_TOKENS: Record<string, string> = {
enter: "Enter",
escape: "Escape",
esc: "Escape",
tab: "Tab",
space: " ",
backspace: "Backspace",
delete: "Delete",
insert: "Insert",
arrowup: "ArrowUp",
arrowdown: "ArrowDown",
arrowleft: "ArrowLeft",
arrowright: "ArrowRight",
up: "ArrowUp",
down: "ArrowDown",
left: "ArrowLeft",
right: "ArrowRight",
home: "Home",
end: "End",
pageup: "PageUp",
pagedown: "PageDown",
comma: ",",
period: ".",
slash: "/",
backslash: "\\",
semicolon: ";",
quote: "'",
minus: "-",
equal: "=",
bracketleft: "[",
bracketright: "]",
backquote: "`",
};
function isSingleAlphanumeric(token: string): boolean {
return /^[a-z0-9]$/.test(token);
}
function isFunctionKey(token: string): boolean {
return /^f([1-9]|1[0-2])$/.test(token);
}
/** Resolves a single non-modifier token to a `KeyboardShortcut["key"]`, or null if unrecognized. */
function resolveKeyToken(token: string): Key | null {
if (token in NAMED_KEY_TOKENS) return NAMED_KEY_TOKENS[token] as Key;
if (isSingleAlphanumeric(token) || isFunctionKey(token)) return token as Key;
return null;
}
/**
* Parses a combo string like "mod+shift+k" into a `KeyboardShortcut`.
* Returns null when the combo is empty, contains an unknown token, does not
* contain exactly one non-modifier key token, or pairs `shift` with a key
* whose reported character shift would alter (see `SHIFT_ALTERED_KEY_TOKENS`).
*/
export function parseCombo(combo: string): KeyboardShortcut | null {
if (!combo || !combo.trim()) return null;
const tokens = combo.split("+").map((token) => token.trim().toLowerCase());
const modifiers: NonNullable<KeyboardShortcut["modifiers"]> = {};
let key: Key | null = null;
let keyToken: string | null = null;
for (const token of tokens) {
if (!token) return null;
if (token in MODIFIER_TOKENS) {
modifiers[MODIFIER_TOKENS[token as ModifierToken]] = true;
continue;
}
const resolvedKey = resolveKeyToken(token);
if (!resolvedKey) return null;
if (key !== null) return null; // more than one non-modifier key token
key = resolvedKey;
keyToken = token;
}
if (key === null || keyToken === null) return null;
if (modifiers.shift && SHIFT_ALTERED_KEY_TOKENS.has(keyToken)) return null;
return Object.keys(modifiers).length > 0 ? { key, modifiers } : { key };
}
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import type { PluginRecord } from "@/lib/types/plugins";
import {
buildConfigurableShortcutEntries,
pluginShortcutId,
resolveShortcutEntry,
type ShortcutEntry,
} from "./plugin-shortcuts";
const PLUGIN_ID = "session-cost";
const KEYBINDING_ID = "toggle-panel";
const NAMESPACED_ID = pluginShortcutId(PLUGIN_ID, KEYBINDING_ID);
function makePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
return {
id: PLUGIN_ID,
api_version: 1,
version: "1.0.0",
display_name: "Session Cost",
description: "",
author: "",
categories: [],
capabilities: {},
status: "active",
install_path: "",
signed: true,
installed_at: "",
restart_count: 0,
...overrides,
};
}
function makeTogglePanelEntry(): ShortcutEntry {
return {
source: "plugin",
id: NAMESPACED_ID,
label: "Session Cost: Toggle panel",
default: { key: "k", modifiers: { ctrlOrCmd: true } },
pluginId: PLUGIN_ID,
keybindingId: KEYBINDING_ID,
};
}
describe("pluginShortcutId", () => {
it("namespaces the id with the plugin id", () => {
expect(pluginShortcutId(PLUGIN_ID, KEYBINDING_ID)).toBe(NAMESPACED_ID);
});
});
describe("buildConfigurableShortcutEntries", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("includes core entries followed by plugin entries", () => {
const plugin = makePlugin({
ui: {
keybindings: [{ id: KEYBINDING_ID, default: "mod+shift+k", description: "Toggle panel" }],
},
});
const entries = buildConfigurableShortcutEntries([plugin]);
const coreEntry = entries.find((e) => e.source === "core" && e.id === "SEARCH");
const pluginEntry = entries.find((e) => e.source === "plugin" && e.id === NAMESPACED_ID);
expect(coreEntry).toBeDefined();
expect(pluginEntry).toMatchObject({
source: "plugin",
id: NAMESPACED_ID,
label: "Session Cost: Toggle panel",
default: { key: "k", modifiers: { ctrlOrCmd: true, shift: true } },
pluginId: PLUGIN_ID,
keybindingId: KEYBINDING_ID,
});
});
it("skips a plugin with no declared keybindings", () => {
const plugin = makePlugin({ ui: { bundle: "ui/bundle.js" } });
const entries = buildConfigurableShortcutEntries([plugin]);
expect(entries.some((e) => e.source === "plugin")).toBe(false);
});
it("warns and skips a keybinding with an unparseable default combo", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const plugin = makePlugin({
ui: { keybindings: [{ id: "bad", default: "banana", description: "Bad" }] },
});
const entries = buildConfigurableShortcutEntries([plugin]);
expect(entries.some((e) => e.source === "plugin")).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("bad"));
});
});
describe("resolveShortcutEntry", () => {
it("returns the default when no override is set", () => {
const entry = makeTogglePanelEntry();
expect(resolveShortcutEntry(entry)).toEqual(entry.default);
});
it("returns the user override when set", () => {
const entry = makeTogglePanelEntry();
const override = { key: "j", modifiers: { shift: true } };
expect(resolveShortcutEntry(entry, { [entry.id]: override })).toEqual(override);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Dynamic layer over `CONFIGURABLE_SHORTCUTS`: builds the same
* label/default shape for plugin-declared keybindings (`ui.keybindings` on a
* `PluginRecord`), so Settings > Keyboard Shortcuts can render and
* user-override plugin shortcuts alongside the static core list without
* widening `ConfigurableShortcutId` to an open string type.
*
* Namespacing: a plugin keybinding's effective override id is
* `plugin:{pluginId}:{keybindingId}` — this is what gets stored under
* `userSettings.keyboardShortcuts` (the same `StoredShortcutOverrides` map
* core shortcuts already use).
*/
import type { PluginRecord } from "@/lib/types/plugins";
import type { KeyboardShortcut } from "./constants";
import { parseCombo } from "./parse-combo";
import {
CONFIGURABLE_SHORTCUTS,
type ConfigurableShortcutId,
type StoredShortcutOverrides,
} from "./shortcut-overrides";
/** Prefix marking a namespaced id as belonging to a plugin, not core. */
export const PLUGIN_SHORTCUT_PREFIX = "plugin:";
export function pluginShortcutId(pluginId: string, keybindingId: string): string {
return `${PLUGIN_SHORTCUT_PREFIX}${pluginId}:${keybindingId}`;
}
/** One configurable shortcut entry, from either the core static list or a plugin manifest. */
export type ShortcutEntry =
| { source: "core"; id: ConfigurableShortcutId; label: string; default: KeyboardShortcut }
| {
source: "plugin";
id: string;
label: string;
default: KeyboardShortcut;
pluginId: string;
keybindingId: string;
};
/**
* The static core shortcuts, in `ShortcutEntry` shape — exported so callers
* that already hold a `ShortcutEntry[]` for plugins (e.g.
* `KeyboardShortcutsCard`, which receives plugin entries as a prop rather
* than raw `PluginRecord[]`) can merge core + plugin entries without
* re-deriving the core list themselves.
*/
export function coreShortcutEntries(): ShortcutEntry[] {
return (Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[]).map((id) => ({
source: "core",
id,
label: CONFIGURABLE_SHORTCUTS[id].label,
default: CONFIGURABLE_SHORTCUTS[id].default,
}));
}
/**
* Builds the plugin-sourced configurable shortcut entries for every active
* plugin's declared `ui.keybindings`. A combo that fails to parse (should
* never happen — the backend validates manifests at registration) is
* skipped with a console warning rather than throwing.
*/
function buildPluginEntries(plugins: PluginRecord[]): ShortcutEntry[] {
const entries: ShortcutEntry[] = [];
for (const plugin of plugins) {
for (const keybinding of plugin.ui?.keybindings ?? []) {
const shortcut = parseCombo(keybinding.default);
if (!shortcut) {
console.warn(
`[plugins] "${plugin.id}" keybinding "${keybinding.id}" has an unparseable default combo "${keybinding.default}"`,
);
continue;
}
entries.push({
source: "plugin",
id: pluginShortcutId(plugin.id, keybinding.id),
label: `${plugin.display_name}: ${keybinding.description}`,
default: shortcut,
pluginId: plugin.id,
keybindingId: keybinding.id,
});
}
}
return entries;
}
/**
* The full list of configurable shortcuts — core (static) entries followed
* by plugin (dynamic) entries — for rendering in Settings > Keyboard
* Shortcuts. Core behavior/order is unchanged from `CONFIGURABLE_SHORTCUTS`.
*/
export function buildConfigurableShortcutEntries(plugins: PluginRecord[]): ShortcutEntry[] {
return [...coreShortcutEntries(), ...buildPluginEntries(plugins)];
}
/** Builds only the plugin-sourced entries — used by callers that already have core entries. */
export function buildPluginShortcutEntries(plugins: PluginRecord[]): ShortcutEntry[] {
return buildPluginEntries(plugins);
}
/** Resolves an entry's effective shortcut: the user override if set, else its default. */
export function resolveShortcutEntry(
entry: ShortcutEntry,
overrides?: StoredShortcutOverrides,
): KeyboardShortcut {
const override = overrides?.[entry.id];
if (override) return override as KeyboardShortcut;
return entry.default;
}
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { comboKey, findShortcutConflicts } from "./shortcut-conflicts";
import type { ShortcutEntry } from "./plugin-shortcuts";
function coreEntry(id: string, shortcut: ShortcutEntry["default"]): ShortcutEntry {
return { source: "core", id: id as never, label: id, default: shortcut };
}
function pluginEntry(
pluginId: string,
keybindingId: string,
shortcut: ShortcutEntry["default"],
): ShortcutEntry {
return {
source: "plugin",
id: `plugin:${pluginId}:${keybindingId}`,
label: `${pluginId}: ${keybindingId}`,
default: shortcut,
pluginId,
keybindingId,
};
}
describe("comboKey", () => {
it("resolves ctrlOrCmd to cmd on mac and ctrl elsewhere", () => {
const shortcut = { key: "k" as const, modifiers: { ctrlOrCmd: true } };
expect(comboKey(shortcut, true)).toBe(comboKey({ key: "k", modifiers: { cmd: true } }, true));
expect(comboKey(shortcut, false)).toBe(
comboKey({ key: "k", modifiers: { ctrl: true } }, false),
);
});
it("returns null for an unbound shortcut", () => {
expect(comboKey({ key: "" as never }, true)).toBeNull();
});
});
describe("findShortcutConflicts", () => {
it("detects a plugin-vs-core conflict", () => {
const search = coreEntry("SEARCH", { key: "k", modifiers: { ctrlOrCmd: true } });
const plugin = pluginEntry("session-cost", "open", {
key: "k",
modifiers: { ctrlOrCmd: true },
});
const conflicts = findShortcutConflicts(
[
{ entry: search, shortcut: search.default },
{ entry: plugin, shortcut: plugin.default },
],
true,
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].entries).toEqual([search, plugin]);
});
it("detects a plugin-vs-plugin conflict", () => {
const pluginA = pluginEntry("plugin-a", "open", { key: "j", modifiers: { shift: true } });
const pluginB = pluginEntry("plugin-b", "open", { key: "j", modifiers: { shift: true } });
const conflicts = findShortcutConflicts(
[
{ entry: pluginA, shortcut: pluginA.default },
{ entry: pluginB, shortcut: pluginB.default },
],
true,
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].entries).toEqual([pluginA, pluginB]);
});
it("reports no conflicts when combos differ", () => {
const pluginA = pluginEntry("plugin-a", "open", { key: "j" });
const pluginB = pluginEntry("plugin-b", "open", { key: "k" });
const conflicts = findShortcutConflicts(
[
{ entry: pluginA, shortcut: pluginA.default },
{ entry: pluginB, shortcut: pluginB.default },
],
true,
);
expect(conflicts).toEqual([]);
});
it("does not flag two unbound shortcuts as conflicting", () => {
const pluginA = pluginEntry("plugin-a", "open", { key: "" as never });
const pluginB = pluginEntry("plugin-b", "open", { key: "" as never });
const conflicts = findShortcutConflicts(
[
{ entry: pluginA, shortcut: pluginA.default },
{ entry: pluginB, shortcut: pluginB.default },
],
true,
);
expect(conflicts).toEqual([]);
});
});
@@ -0,0 +1,62 @@
/**
* Pure conflict detection over a list of resolved (effective) keyboard
* shortcuts — used to warn about core-vs-plugin and plugin-vs-plugin combo
* collisions in Settings > Keyboard Shortcuts. Detection only; UI surfacing
* is a lightweight per-row indicator (see `keyboard-shortcuts-card.tsx`).
*/
import type { KeyboardShortcut } from "./constants";
import { isUnboundShortcut } from "./shortcut-overrides";
import type { ShortcutEntry } from "./plugin-shortcuts";
/**
* Canonicalizes a shortcut to a comparable string, resolving `ctrlOrCmd` to
* the concrete modifier the given platform would fire (mirrors
* `matchesShortcut`'s runtime behavior). Returns null for an unbound
* shortcut — unbound entries never conflict with anything.
*/
export function comboKey(shortcut: KeyboardShortcut, isMacPlatform: boolean): string | null {
if (isUnboundShortcut(shortcut)) return null;
const modifiers = shortcut.modifiers;
const ctrl = !!modifiers?.ctrl || !!(modifiers?.ctrlOrCmd && !isMacPlatform);
const cmd = !!modifiers?.cmd || !!(modifiers?.ctrlOrCmd && isMacPlatform);
const alt = !!modifiers?.alt;
const shift = !!modifiers?.shift;
return [shortcut.key.toLowerCase(), ctrl, cmd, alt, shift].join("|");
}
/** A group of two or more entries whose effective combo collides. */
export interface ShortcutConflictGroup {
comboKey: string;
entries: ShortcutEntry[];
}
/**
* Groups `entries` (already resolved to their effective shortcut, e.g. via
* `resolveShortcutEntry`) by collision. Only groups with 2+ members are
* returned. Dispatch order for a colliding combo is registration order (see
* `hooks/use-plugin-shortcuts.ts`) — this helper only detects and reports,
* it doesn't resolve precedence.
*/
export function findShortcutConflicts(
resolved: { entry: ShortcutEntry; shortcut: KeyboardShortcut }[],
isMacPlatform: boolean,
): ShortcutConflictGroup[] {
const groups = new Map<string, ShortcutEntry[]>();
for (const { entry, shortcut } of resolved) {
const key = comboKey(shortcut, isMacPlatform);
if (key === null) continue;
const group = groups.get(key);
if (group) {
group.push(entry);
} else {
groups.set(key, [entry]);
}
}
return Array.from(groups.entries())
.filter(([, list]) => list.length > 1)
.map(([key, list]) => ({ comboKey: key, entries: list }));
}
+15
View File
@@ -4,6 +4,21 @@
import type { KeyboardShortcut, Platform } from "./constants";
/**
* Returns true if the keydown event's target is a text input, textarea, or
* contenteditable element. Shared by every global keydown dispatcher
* (`useAppShortcuts`, `usePluginShortcuts`) so typing in a field never
* triggers a bound shortcut.
*/
export function isEditableKeydownTarget(event: KeyboardEvent): boolean {
const tag = (event.target as HTMLElement)?.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
(event.target as HTMLElement)?.isContentEditable === true
);
}
/**
* Detect the current platform
*/
+18
View File
@@ -140,4 +140,22 @@ describe("buildHostApi", () => {
const host = buildHostApi("jira", createAppStore(), "light");
expect(host.pluginId).toBe("jira");
});
it("routes openModal to the modal manager, scoped to this plugin's id", async () => {
const { pluginModalManager } = await import("./modal-manager");
const host = buildHostApi("jira", createAppStore(), "light");
const before = pluginModalManager.getSnapshot().length;
const handle = host.openModal({ content: () => null, title: "Test" });
const snapshot = pluginModalManager.getSnapshot();
expect(snapshot).toHaveLength(before + 1);
expect(snapshot[snapshot.length - 1]).toMatchObject({
pluginId: "jira",
options: { title: "Test" },
});
handle.close();
expect(pluginModalManager.getSnapshot()).toHaveLength(before);
});
});
+2
View File
@@ -71,6 +71,7 @@ import { TaskCreateDialog } from "@/components/task-create-dialog";
import { getBackendConfig } from "@/lib/config";
import { softNavigate } from "@/lib/routing/client-router";
import type { AppState } from "@/lib/state/store";
import { pluginModalManager } from "./modal-manager";
import type { PluginHostApi } from "./types";
/**
@@ -189,6 +190,7 @@ export function buildHostApi(
ui: PLUGIN_UI,
theme,
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
openModal: (options) => pluginModalManager.openModal(pluginId, options),
};
}
+36
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import type { Window as HappyDOMWindow } from "happy-dom";
import { loadPlugins, unloadPlugin } from "./host";
import { pluginModalManager } from "./modal-manager";
import { pluginRegistry } from "./registry";
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
@@ -34,6 +35,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
ui: {},
theme: "light",
navigate: () => {},
openModal: () => ({ close: () => {} }),
};
}
@@ -331,6 +333,40 @@ describe("unloadPlugin", () => {
});
});
describe("unloadPlugin — plugin modal cleanup", () => {
afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_A_ID);
});
it("closes every modal opened by the unloaded plugin", async () => {
const importer = fakeImporterFor({
[BUNDLE_JS_URL]: (win) =>
(win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_A_ID, {
initialize: () => {},
}),
});
await loadPlugins(
[activePlugin({ id: PLUGIN_UNLOAD_A_ID, bundleUrl: BUNDLE_JS_URL })],
makeHostFactory,
importer,
);
const before = pluginModalManager.getSnapshot().length;
pluginModalManager.openModal(PLUGIN_UNLOAD_A_ID, { content: () => null });
const otherHandle = pluginModalManager.openModal("some-other-plugin", { content: () => null });
expect(pluginModalManager.getSnapshot()).toHaveLength(before + 2);
unloadPlugin(PLUGIN_UNLOAD_A_ID);
const snapshot = pluginModalManager.getSnapshot();
expect(snapshot).toHaveLength(before + 1);
expect(snapshot.some((m) => m.pluginId === PLUGIN_UNLOAD_A_ID)).toBe(false);
expect(snapshot.some((m) => m.pluginId === "some-other-plugin")).toBe(true);
otherHandle.close();
});
});
describe("loadPlugins — initialize() timeout isolation", () => {
afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_HANG_A_ID);
+2
View File
@@ -16,6 +16,7 @@
* tab must reuse the cached registration instead of relying on re-import.
*/
import { getBackendConfig } from "@/lib/config";
import { pluginModalManager } from "./modal-manager";
import { pluginRegistry } from "./registry";
import type { ActivePlugin, KandevPlugin, PluginHostApi } from "./types";
@@ -195,6 +196,7 @@ export function unloadPlugin(id: string, options?: { evictCache?: boolean }): vo
console.error(`[plugins] error destroying plugin "${id}"`, error);
} finally {
pluginRegistry.unregisterPlugin(id);
pluginModalManager.closeAllForPlugin(id);
removeStyles(id);
if (options?.evictCache) {
registeredPlugins.delete(id);
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { pluginModalManager } from "./modal-manager";
import type { PluginModalOptions } from "./types";
function noopContent() {
return null;
}
const baseOptions: PluginModalOptions = { content: noopContent };
describe("pluginModalManager", () => {
it("open() returns a handle and adds the modal to the snapshot", () => {
const before = pluginModalManager.getSnapshot().length;
const handle = pluginModalManager.openModal("jira", baseOptions);
const snapshot = pluginModalManager.getSnapshot();
expect(snapshot).toHaveLength(before + 1);
expect(snapshot[snapshot.length - 1].pluginId).toBe("jira");
handle.close();
});
it("close() removes only the closed modal instance", () => {
const handleA = pluginModalManager.openModal("jira", baseOptions);
const handleB = pluginModalManager.openModal("jira", baseOptions);
const before = pluginModalManager.getSnapshot().length;
handleA.close();
const snapshot = pluginModalManager.getSnapshot();
expect(snapshot).toHaveLength(before - 1);
expect(snapshot.some((m) => m.pluginId === "jira")).toBe(true);
handleB.close();
});
it("supports multiple concurrently open modals", () => {
const before = pluginModalManager.getSnapshot().length;
const handleA = pluginModalManager.openModal("jira", baseOptions);
const handleB = pluginModalManager.openModal("linear", baseOptions);
expect(pluginModalManager.getSnapshot()).toHaveLength(before + 2);
handleA.close();
handleB.close();
expect(pluginModalManager.getSnapshot()).toHaveLength(before);
});
it("closeAllForPlugin removes only that plugin's modals", () => {
const jiraHandle = pluginModalManager.openModal("jira", baseOptions);
const linearHandle = pluginModalManager.openModal("linear", baseOptions);
const before = pluginModalManager.getSnapshot().length;
pluginModalManager.closeAllForPlugin("jira");
const snapshot = pluginModalManager.getSnapshot();
expect(snapshot).toHaveLength(before - 1);
expect(snapshot.some((m) => m.pluginId === "jira")).toBe(false);
expect(snapshot.some((m) => m.pluginId === "linear")).toBe(true);
void jiraHandle;
linearHandle.close();
});
it("generates monotonically increasing, unique instance ids", () => {
const handleA = pluginModalManager.openModal("jira", baseOptions);
const snapshotA = pluginModalManager.getSnapshot();
const idA = snapshotA[snapshotA.length - 1].instanceId;
const handleB = pluginModalManager.openModal("jira", baseOptions);
const snapshotB = pluginModalManager.getSnapshot();
const idB = snapshotB[snapshotB.length - 1].instanceId;
expect(idA).not.toBe(idB);
handleA.close();
handleB.close();
});
it("close() is a no-op when the modal is already closed", () => {
const handle = pluginModalManager.openModal("jira", baseOptions);
handle.close();
const before = pluginModalManager.getSnapshot().length;
expect(() => handle.close()).not.toThrow();
expect(pluginModalManager.getSnapshot()).toHaveLength(before);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Reactive singleton backing `host.openModal(...)` (docs/plans/plugins/PLUGIN-API.md).
*
* Tracks every open plugin modal as a list of `{ instanceId, pluginId, options }`
* and exposes a tiny external-store subscription so `<PluginModalHost/>`
* re-renders whenever a modal opens or closes — same pattern as
* `lib/plugins/registry.ts`'s `pluginRegistry` singleton.
*/
import { useSyncExternalStore } from "react";
import type { PluginModalHandle, PluginModalOptions } from "./types";
export interface OpenPluginModal {
instanceId: string;
pluginId: string;
options: PluginModalOptions;
}
let nextInstanceId = 0;
class PluginModalManager {
private modals: OpenPluginModal[] = [];
private listeners = new Set<() => void>();
private snapshot: OpenPluginModal[] = [];
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): OpenPluginModal[] => this.snapshot;
/** Opens a modal owned by `pluginId`; returns a handle to close it. */
openModal(pluginId: string, options: PluginModalOptions): PluginModalHandle {
const instanceId = `plugin-modal-${(nextInstanceId += 1)}`;
this.modals = [...this.modals, { instanceId, pluginId, options }];
this.notify();
return { close: () => this.close(instanceId) };
}
/** Removes every modal owned by `pluginId` (called on plugin disable/unload). */
closeAllForPlugin(pluginId: string): void {
const before = this.modals.length;
this.modals = this.modals.filter((modal) => modal.pluginId !== pluginId);
if (this.modals.length !== before) this.notify();
}
/** Removes a single modal by `instanceId`, e.g. from the host's Dialog `onOpenChange`. */
close(instanceId: string): void {
const before = this.modals.length;
this.modals = this.modals.filter((modal) => modal.instanceId !== instanceId);
if (this.modals.length !== before) this.notify();
}
private notify(): void {
this.snapshot = this.modals;
this.listeners.forEach((listener) => listener());
}
}
export const pluginModalManager = new PluginModalManager();
/** Snapshot hook: re-renders the caller whenever any plugin modal opens/closes. */
export function usePluginModals(): OpenPluginModal[] {
return useSyncExternalStore(
pluginModalManager.subscribe,
pluginModalManager.getSnapshot,
pluginModalManager.getSnapshot,
);
}
+68 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { pluginRegistry } from "./registry";
const TASK_SIDEBAR_SLOT = "task-sidebar";
@@ -214,3 +214,70 @@ describe("pluginRegistry — route options and plugin names", () => {
expect(pluginRegistry.getPluginName("plugin-a")).toBeUndefined();
});
});
describe("pluginRegistry — keybinding handlers", () => {
afterEach(() => {
cleanup("plugin-a", "plugin-b");
});
it("registers a keybinding handler scoped to the owning plugin", () => {
const scopedA = pluginRegistry.forPlugin("plugin-a");
const scopedB = pluginRegistry.forPlugin("plugin-b");
const handlerA = () => {};
const handlerB = () => {};
scopedA.registerKeybinding("open", handlerA);
scopedB.registerKeybinding("open", handlerB);
expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBe(handlerA);
expect(pluginRegistry.getKeybindingHandler("plugin-b", "open")).toBe(handlerB);
expect(pluginRegistry.getKeybindingHandlers()).toEqual([
{ id: "open", handler: handlerA, pluginId: "plugin-a" },
{ id: "open", handler: handlerB, pluginId: "plugin-b" },
]);
});
it("revokes keybinding handlers on unregisterPlugin", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerKeybinding("open", () => {});
pluginRegistry.unregisterPlugin("plugin-a");
expect(pluginRegistry.getKeybindingHandlers()).toEqual([]);
expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBeUndefined();
});
it("warns when registering a handler for an id not declared in the manifest", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]);
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerKeybinding("not-declared", () => {});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not-declared"));
// The handler is still stored despite the warning.
expect(pluginRegistry.getKeybindingHandler("plugin-a", "not-declared")).toBeDefined();
warnSpy.mockRestore();
});
it("does not warn when the declared id list has not been synced yet", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerKeybinding("anything", () => {});
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("does not warn when the id is declared in the manifest", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]);
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerKeybinding("open", () => {});
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
+58 -1
View File
@@ -25,6 +25,13 @@ interface Owned<T> {
value: T;
}
/** A handler bound via `PluginRegistry.registerKeybinding`. */
export interface PluginKeybindingHandler {
/** Plugin-local keybinding id (matches `ui.keybindings[].id`). */
id: string;
handler: (event: KeyboardEvent) => void;
}
export interface RouteRegistration {
path: string;
Component: ComponentType;
@@ -66,9 +73,18 @@ class PluginRegistryStore {
private navItems: Owned<NavItem>[] = [];
private slotComponents: Owned<SlotRegistration>[] = [];
private wsHandlers: Owned<WsHandlerRegistration>[] = [];
private keybindingHandlers: Owned<PluginKeybindingHandler>[] = [];
private nextSlotRegistrationId = 0;
/** Display names from the boot payload, used for derived page-chrome titles. */
private pluginNames = new Map<string, string>();
/**
* Keybinding ids declared in each plugin's `ui.keybindings` manifest,
* synced by the shortcut dispatcher (`hooks/use-plugin-shortcuts.ts`) from
* the plugin records store. Used only to warn on `registerKeybinding`
* calls for an id the manifest never declared — an empty/missing entry
* (descriptors not loaded yet) skips the check rather than false-warning.
*/
private declaredKeybindingIds = new Map<string, Set<string>>();
private listeners = new Set<() => void>();
private version = 0;
@@ -122,6 +138,26 @@ class PluginRegistryStore {
this.notify();
}
registerKeybinding(pluginId: string, id: string, handler: (event: KeyboardEvent) => void): void {
const declared = this.declaredKeybindingIds.get(pluginId);
if (declared && !declared.has(id)) {
console.warn(
`[plugins] "${pluginId}" registered a keybinding handler for id "${id}", which is not declared in its ui.keybindings manifest`,
);
}
this.keybindingHandlers.push({ pluginId, value: { id, handler } });
this.notify();
}
/**
* Records the keybinding ids declared in `pluginId`'s `ui.keybindings`
* manifest, so `registerKeybinding` can warn on an undeclared id. Safe to
* call repeatedly (e.g. every time the plugin records store refreshes).
*/
setDeclaredKeybindingIds(pluginId: string, ids: string[]): void {
this.declaredKeybindingIds.set(pluginId, new Set(ids));
}
/** Bulk-revoke every registration owned by `pluginId` (disable/uninstall). */
unregisterPlugin(pluginId: string): void {
const before = this.totalCount();
@@ -130,7 +166,9 @@ class PluginRegistryStore {
this.navItems = removeByPlugin(this.navItems, pluginId);
this.slotComponents = removeByPlugin(this.slotComponents, pluginId);
this.wsHandlers = removeByPlugin(this.wsHandlers, pluginId);
this.keybindingHandlers = removeByPlugin(this.keybindingHandlers, pluginId);
this.pluginNames.delete(pluginId);
this.declaredKeybindingIds.delete(pluginId);
if (this.totalCount() !== before) this.notify();
}
@@ -185,6 +223,23 @@ class PluginRegistryStore {
.map((entry) => entry.value.handler);
}
/**
* All registered keybinding handlers plus their owning pluginId, in
* registration order. Registration order is the dispatch-order tiebreaker
* when two plugins bind the same effective combo (see
* `hooks/use-plugin-shortcuts.ts`).
*/
getKeybindingHandlers(): (PluginKeybindingHandler & { pluginId: string })[] {
return this.keybindingHandlers.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
}
/** The `pluginId`'s bound handler for `id`, if any (first match wins). */
getKeybindingHandler(pluginId: string, id: string): ((event: KeyboardEvent) => void) | undefined {
return this.keybindingHandlers.find(
(entry) => entry.pluginId === pluginId && entry.value.id === id,
)?.value.handler;
}
/** Registry view scoped to one plugin — matches the frozen `PluginRegistry` contract. */
forPlugin(pluginId: string, pluginName?: string): PluginRegistry {
if (pluginName) this.pluginNames.set(pluginId, pluginName);
@@ -196,6 +251,7 @@ class PluginRegistryStore {
this.registerSettingsRoute(pluginId, path, Component),
registerComponent: (slot, Component) => this.registerComponent(pluginId, slot, Component),
registerWsHandler: (action, handler) => this.registerWsHandler(pluginId, action, handler),
registerKeybinding: (id, handler) => this.registerKeybinding(pluginId, id, handler),
};
}
@@ -205,7 +261,8 @@ class PluginRegistryStore {
this.settingsRoutes.length +
this.navItems.length +
this.slotComponents.length +
this.wsHandlers.length
this.wsHandlers.length +
this.keybindingHandlers.length
);
}
+34
View File
@@ -112,6 +112,24 @@ export type SlotComponent = ReactType.ComponentType<{ slotProps?: unknown }>;
/** WS action payload handler registered by a plugin. */
export type WsHandler = (payload: unknown) => void;
/** Options accepted by `host.openModal(...)`. */
export interface PluginModalOptions {
/** Modal title, rendered in a `DialogHeader`/`DialogTitle`. Omit to render no header title. */
title?: string;
/** Component rendered inside the modal body — reuses the slot-component contract. */
content: SlotComponent;
/** Modal width, mapped to the host's Dialog size classes. Default: "md". */
size?: "sm" | "md" | "lg" | "xl";
/** Whether the modal can be dismissed via overlay click or Escape. Default: true. */
dismissible?: boolean;
}
/** Handle returned by `host.openModal(...)`, used to close that modal instance. */
export interface PluginModalHandle {
/** Closes this modal instance. No-op if already closed. */
close(): void;
}
/**
* API surface passed as the second argument to `KandevPlugin.initialize`.
* Plugins must render with `host.React` / `host.jsx` — no bundled React.
@@ -143,6 +161,12 @@ export interface PluginHostApi {
theme: "light" | "dark";
/** Soft SPA navigation (history push/replace + re-render), same as the app's router. */
navigate(href: string, options?: { replace?: boolean }): void;
/**
* Imperatively opens a modal window rendered by the host's `<PluginModalHost/>`
* (mounted once at the app root). Independent of keybindings — a keybinding
* handler may call it, but it works from any plugin code path.
*/
openModal(options: PluginModalOptions): PluginModalHandle;
}
/**
@@ -174,6 +198,16 @@ export interface PluginRegistry {
registerComponent(slot: PluginSlotName, Component: SlotComponent): void;
/** WS action handler, bridged into the existing `lib/ws` dispatch. */
registerWsHandler(action: string, handler: WsHandler): void;
/**
* Bind a handler to a keybinding declared in this plugin's manifest
* (`ui.keybindings[].id`). The host resolves the effective combo (user
* override if set, else the manifest `default`) and dispatches globally,
* skipping editable targets the same way core app shortcuts do. Binding an
* `id` the manifest didn't declare still stores the handler (a console
* warning is logged) since the dispatcher's effective-shortcut resolution
* still keys off the manifest list.
*/
registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;
}
/** Shape every plugin bundle registers via `window.registerKandevPlugin(id, plugin)`. */
+14
View File
@@ -29,10 +29,24 @@ export interface PluginUIPage {
surface: string;
}
/**
* A single keyboard shortcut a plugin's UI bundle wants kandev to register
* and dispatch on its behalf. `id` is plugin-local (unique within the
* plugin, not globally); `default` is a combo like "mod+shift+k" ("mod"
* resolves to Cmd on macOS, Ctrl elsewhere). Only meaningful alongside
* `bundle` — the backend rejects keybindings declared without one.
*/
export interface PluginKeybinding {
id: string;
default: string;
description: string;
}
export interface PluginUISection {
pages?: PluginUIPage[];
bundle?: string;
styles?: string[];
keybindings?: PluginKeybinding[];
}
/**
+2
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@/app/globals.css";
import { PluginModalHost } from "@/components/plugins/plugin-modal-host";
import { StateProvider } from "@/components/state-provider";
import { PluginBootBridge } from "@/lib/plugins/plugin-boot-bridge";
import { AppShell } from "./app-shell";
@@ -12,6 +13,7 @@ function App({ payload }: { payload: BootPayload }) {
return (
<StateProvider initialState={payload.initialState ?? {}}>
<PluginBootBridge plugins={payload.plugins} />
<PluginModalHost />
<AppShell>
<SpaRoutes routeData={payload.routeData} />
</AppShell>
+34
View File
@@ -59,6 +59,21 @@ interface PluginHostApi {
// path as the app router, so plugin pages can link into native routes
// (e.g. /t/{taskId}) without a full reload.
navigate(href: string, options?: { replace?: boolean }): void;
// Imperatively opens a modal window rendered by the host's <PluginModalHost/>
// (mounted once at the app root, isolated behind its own error boundary).
// Independent of keybindings — any plugin code path may call it.
openModal(options: PluginModalOptions): PluginModalHandle;
}
interface PluginModalOptions {
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
dismissible?: boolean; // overlay click / Escape close the modal; default true
}
interface PluginModalHandle {
close(): void; // closes this modal instance; no-op if already closed
}
```
@@ -153,6 +168,25 @@ interface PluginRegistry {
// WS action handler. Bridged into the existing lib/ws dispatch; called with the
// decoded message payload for that action string.
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
// Binds a handler to a keybinding declared in this plugin's manifest
// (ui.keybindings[].id — { id, default, description }, see manifest schema).
// The host resolves the effective combo (user override if the user
// rebound it, else the manifest default) and dispatches globally, skipping
// editable targets the same way core app shortcuts do. Combos are
// user-overridable in Settings > Keyboard Shortcuts, namespaced
// `plugin:{pluginId}:{id}`. Binding an id the manifest didn't declare still
// stores the handler (a console warning is logged) since the dispatcher's
// effective-shortcut resolution keys off the manifest list.
//
// Combo grammar (manifest `default` and any user override): `+`-separated
// tokens, one of the modifiers `mod|ctrl|cmd|meta|alt|option|shift`
// (repeatable) plus exactly one key token. `mod` resolves to Cmd on macOS
// and Ctrl elsewhere (⌘/Ctrl). `shift` may not be combined with a digit or
// symbol key (e.g. `shift+1`, `shift+slash`) — Shift changes the character
// a browser reports for those keys, so the combo could never dispatch; both
// the manifest validator and the frontend parser reject it.
registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;
}
```
+71
View File
@@ -308,6 +308,9 @@ interface PluginRegistry {
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
// WS action handler, bridged into the existing lib/ws dispatch.
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
// Bind a handler to a keybinding declared in this plugin's manifest
// (ui.keybindings[].id). See "Keybindings" below.
registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;
}
interface NavItem {
@@ -364,6 +367,19 @@ interface PluginHostApi {
theme: "light" | "dark";
// Soft SPA navigation (history push/replace), same as the app's own router.
navigate(href: string, options?: { replace?: boolean }): void;
// Imperatively opens a modal window. See "Modal windows" below.
openModal(options: PluginModalOptions): PluginModalHandle;
}
interface PluginModalOptions {
title?: string; // rendered in DialogHeader/DialogTitle; omit for no title
content: React.ComponentType; // reuses the slot-component contract
size?: "sm" | "md" | "lg" | "xl"; // default "md"
dismissible?: boolean; // overlay click / Escape; default true
}
interface PluginModalHandle {
close(): void; // no-op if already closed
}
```
@@ -381,6 +397,28 @@ here for routes that opt out and render their own chrome), and
create-task flow (repo/branch/agent pickers, validation) instead of POSTing
directly. See `apps/web/lib/plugins/host-api.ts` for the exact current list.
### Modal windows
`host.openModal(options)` imperatively opens a host-owned Dialog — rendered
by a `<PluginModalHost/>` mounted once at the app root, isolated behind its
own error boundary, and auto-closed if the plugin is disabled or uninstalled
while it's open. It's independent of keybindings: call it from a keybinding
handler, a nav route, a slot component, or a WS handler. It complements the
declarative `host.ui.Dialog` — reach for `host.ui.Dialog` when a dialog is
embedded in a slot's own render tree, and `host.openModal` when you need to
pop one open imperatively from anywhere in your plugin's code.
```js
const handle = host.openModal({
title: "Acme settings",
content: SettingsPanel,
size: "lg",
});
// later, e.g. after the panel calls back on save:
handle.close();
```
## Named slots
`registerComponent(slot, Component)` injects a component into a host-defined
@@ -594,6 +632,39 @@ reload, and each contribution has its own error boundary. A full-bleed route
(`topbar: false`) owns its own chrome; mount the host Status trigger there if that
route should expose Status.
## Keybindings
Declare each keybinding in `manifest.yaml`'s `ui.keybindings` (requires
`ui.bundle`), then bind a handler at runtime with `registry.registerKeybinding`:
```yaml
ui:
bundle: "/ui/bundle.js"
keybindings:
- id: "open-panel" # plugin-local slug: ^[a-z0-9][a-z0-9-]*$
default: "mod+shift+j" # combo grammar, see below
description: "Open the Acme panel"
```
```js
registry.registerKeybinding("open-panel", () => {
host.openModal({ title: "Acme panel", content: AcmePanel });
});
```
The `default` combo is `+`-separated: zero or more modifiers from
`mod | ctrl | cmd | meta | alt | option | shift` (`mod` resolves to ⌘ on
macOS and Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not
combine with a digit or symbol key — the browser reports the shifted glyph
for those keys (e.g. `shift+1` reports `!`), so the combo could never match.
A plugin keybinding is **user-overridable** at **Settings > Keyboard
Shortcuts**, namespaced `plugin:{pluginId}:{id}` so two plugins can each
declare their own `open-panel` id without colliding. If a plugin's effective
combo conflicts with a core kandev shortcut, the core shortcut always wins —
the plugin handler is skipped for that combo — and the conflict is surfaced
in that same settings page so the user can rebind it.
## Three integration patterns
Both example repos are full, working plugins — read them rather than
+8
View File
@@ -61,6 +61,10 @@ config_schema:
ui: # optional native frontend plugin
bundle: "/ui/bundle.js" # root-relative
styles: ["/ui/plugin.css"] # optional, root-relative
keybindings: # optional, requires ui.bundle
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
default: "mod+shift+j" # combo grammar, see field reference
description: "Open the Acme panel"
```
## Field reference
@@ -96,6 +100,10 @@ ui: # optional native frontend plugin
| `ui.pages[].title` | yes* | string | Display title. |
| `ui.pages[].path` | yes* | string | Route path for the page. |
| `ui.pages[].surface` | yes* | string | Where the page mounts. Enum, one of: `settings` · `task-panel` · `main-nav`. Any other value is a validation error. |
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
| `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. |
| `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
| `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
`ui.pages` is declarative manifest metadata only. A native bundle's runtime
nav items, icons, and per-route title-bar chrome (`registerNavItem`,
+5 -1
View File
@@ -38,7 +38,11 @@ alongside kandev's first-party integration links in the main sidebar's
plugin is installed and active. Bundles can also inject components into
host-defined slots, including **icon buttons in the chat composer toolbar**
(beside the model picker, mic, and send button), so an active plugin can add
its own action right where you message an agent.
its own action right where you message an agent. A bundle can also declare
**keybindings** (user-overridable at **Settings > Keyboard Shortcuts**, with
core shortcuts always winning on a conflict) and open host-owned **modal
windows** from anywhere in its code — see [Authoring a
plugin](plugins-authoring.md) for both.
### Global Status contributions
+14
View File
@@ -500,6 +500,20 @@ Mattermost-webapp model), not iframes. The full contract lives in
bundle/`initialize` is caught and never breaks boot; slot components render behind
error boundaries. Plugin JS otherwise runs with full in-origin store access —
hard sandboxing (workers/realms) is future work (see Out of scope).
- **Keybindings:** a plugin declares `ui.keybindings: [{ id, default, description }]`
in its manifest (`default` is a combo string like `mod+shift+k`, validated at
registration time). `registry.registerKeybinding(id, handler)` binds a JS handler
to a declared id; the host resolves the effective combo (a user override from
Settings > Keyboard Shortcuts if set, else the manifest `default`) and dispatches
it globally, skipping editable targets the same way core app shortcuts do. User
overrides are namespaced `plugin:{pluginId}:{id}` so they survive independently
per plugin.
- **Modals:** `host.openModal({ title?, content, size?, dismissible? })` imperatively
opens a modal rendered by the host's `<PluginModalHost/>` (mounted once at the app
root, isolated behind its own error boundary) and returns `{ close() }` to close
that instance. `content` reuses the slot-component contract (rendered with the
host React instance). Independent of keybindings — any plugin code path may call
it, including a keybinding handler.
## State machine